What You Will Build

A full-screen particle emitter where 50 glowing circles drift upward against a dark background, wrapping around when they reach the top. This effect creates ambient, living backgrounds for splash screens, music players, or atmospheric app experiences.

Why Canvas-Based Particles Matter

Composable-per-particle systems hit performance walls above 30-40 particles because each triggers recomposition. Using a single Canvas composable with drawCircle calls is dramatically faster because it bypasses the composition tree entirely. This is the same approach game engines use.

Key Compose Concepts

  • Canvas composable for efficient bulk drawing without per-element recomposition.
  • LaunchedEffect with delay loop for 60fps animation ticks.
  • Data class mutation pattern for updating particle positions each frame.

Step 1: Define the Particle Data Class

data class Particle(
    var x: Float,
    var y: Float,
    var vx: Float,      // horizontal velocity
    var vy: Float,      // vertical velocity (negative = upward)
    var alpha: Float,    // transparency
    var radius: Float    // circle size
)

Step 2: Initialize Particles with Random Properties

val particles = remember {
    (1..50).map {
        Particle(
            x = (0..1000).random().toFloat(),
            y = (0..2000).random().toFloat(),
            vx = (-2..2).random().toFloat(),
            vy = (-3..-1).random().toFloat(),
            alpha = (30..100).random() / 100f,
            radius = (2..8).random().toFloat()
        )
    }.toMutableList()
}

Step 3: Animate with a Frame Loop

A LaunchedEffect runs an infinite loop with 16ms delay (approximately 60fps). Each tick updates every particle position and wraps particles that fly off the top:

var tick by remember { mutableIntStateOf(0) }

LaunchedEffect(Unit) {
    while (true) {
        delay(16)
        tick++
        particles.forEachIndexed { i, p ->
            particles[i] = p.copy(
                x = p.x + p.vx,
                y = if (p.y + p.vy < 0) 2000f
                    else p.y + p.vy
            )
        }
    }
}

Step 4: Draw on Canvas

Box(
    modifier = Modifier
        .fillMaxSize()
        .background(Color(0xFF0A0A1A))
) {
    Canvas(Modifier.fillMaxSize()) {
        particles.forEach { p ->
            drawCircle(
                color = Color(0xFF6C63FF).copy(alpha = p.alpha),
                radius = p.radius,
                center = Offset(
                    p.x % size.width,
                    p.y % size.height
                )
            )
        }
    }
}

Tips and Pitfalls

  • Modulo wrapping (% size.width) ensures particles that drift off-screen horizontally wrap back into view.
  • 16ms delay matches 60fps. For smoother animation on high refresh displays, use withFrameMillis from the animation framework.
  • MutableList vs State: The mutable list plus a tick counter is a deliberate choice; the tick state change triggers recomposition which re-reads the Canvas.
  • Scale to hundreds of particles easily since Canvas draws are GPU-batched, unlike individual Composable nodes.

Minimum SDK: API 24+ with Compose BOM 2024.01+

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.