Confetti Burst

Tap anywhere and a one shot burst of rotating paper rectangles erupts. Each piece carries its own velocity, rotation speed, lateral wobble, lifetime, and color.

Compose APIs
withFrameNanosCanvasdrawIntoCanvasparticle system
Try tweaking
BURST_COUNTGRAVITYSPEED_MIN/MAXSPREAD_DEGLAUNCH_ANGLE_DEGAIR_DRAGWOBBLE_AMPROT_SPEED_MAXPALETTE

Compose ships a rich set of high level animation APIs, from animate*AsState to Transition to Animatable. They are built for a small number of values that change in well defined ways. The moment you want seventy independent pieces of paper, each with its own velocity, rotation, wobble phase, lifetime, and fade, those abstractions stop helping. The primitives that survive at this layer are withFrameNanos for vsync aligned ticks and Canvas for direct drawing into a single draw scope.

In this article, you'll explore a confetti burst built on those two primitives, covering how taps spawn particle bursts, how the per frame simulation loop runs on withFrameNanos, the physics that govern gravity, drag, and lateral wobble, the single pass Canvas that draws every particle, and the constants you can tweak to reshape the storm.

How the example is structured

The example is AnimationExample16, a self contained composable that does three things. It owns a mutableStateListOf<Confetti> that holds every live particle. It runs a LaunchedEffect whose body is an infinite while (true) loop driven by withFrameNanos, advancing each particle by the elapsed delta time. And it draws a Canvas that iterates the same list and renders every particle in one pass. Tap input is wired through pointerInput { detectTapGestures { ... } }, which appends a fresh batch of particles into the list at the tap location.

The interesting design choice is that the simulation state lives in plain mutable fields on the Confetti class, not in mutableStateOf. Compose only needs to recompose when something draw relevant changes, and that signal is provided by a single frameNanos state value updated once per frame. The draw block reads frameNanos, so it invalidates every frame. Particle fields can stay as ordinary var properties, avoiding state object overhead per particle.

Modeling a particle

Every particle is an instance of a small private class. Looking at the declaration:

private class Confetti(
  var x: Float,
  var y: Float,
  var vx: Float,
  var vy: Float,
  var rotation: Float,
  val rotSpeed: Float,
  var ageMs: Float,
  val lifetimeMs: Float,
  val color: Color,
  val w: Float,
  val h: Float,
  val wobblePhase: Float,
)

Position lives in x and y, both in pixels. Velocity lives in vx and vy, in pixels per second. Rotation in degrees and rotSpeed in degrees per second drive the spinning paper effect. The ageMs field grows every frame, and once it crosses lifetimeMs the particle is removed. The wobblePhase field is a random offset into a sine wave so that two particles spawned in the same frame do not flutter in lockstep.

Notice that rotSpeed, lifetimeMs, color, w, h, and wobblePhase are val. They are sampled once at spawn time and never change. Only the simulation outputs, position, velocity, rotation, and age, are var. This split keeps the per frame mutation surface small and the data model honest about which fields evolve.

The simulation loop with withFrameNanos

The whole simulation is one LaunchedEffect. The skeleton looks like this:

LaunchedEffect(GRAVITY, AIR_DRAG, WOBBLE_AMP, WOBBLE_FREQ, heightPx) {
  var lastFrame = withFrameNanos { it }
  while (true) {
    val now = withFrameNanos { it }
    val dtMs = min((now - lastFrame) / 1_000_000f, 50f)
    val dtSec = dtMs / 1000f
    lastFrame = now
    // ...update every particle by dtSec
    frameNanos = now
  }
}

withFrameNanos is the right primitive for this layer for three reasons. It suspends until the next Choreographer frame, which means the loop is naturally vsync aligned and never busy spins. It hands you a nanosecond timestamp from the same clock the framework uses, so deltas are accurate and free of monotonic drift. And because it is a suspending function, it composes cleanly with structured concurrency, the loop dies the moment the LaunchedEffect leaves composition.

The dtMs value is clamped to 50f. If the app pauses or a frame is dropped, the next delta could be hundreds of milliseconds, and integrating gravity over that interval would shoot every particle off screen. Clamping caps the largest step the integrator can take, trading a brief slow motion blip for stability.

The last line of each iteration writes frameNanos = now. That single state assignment is what tells Compose to invalidate the Canvas, since the draw block reads frameNanos. One state write per frame, regardless of particle count.

Physics: gravity, drag, wobble

Inside the loop, every particle is updated in place. The core of the integrator is short:

val damping = exp(-AIR_DRAG * dtSec)
// for each particle p:
p.vy += GRAVITY * dtSec
p.vx *= damping
p.vy *= damping
val wobble = sin(p.ageMs * WOBBLE_FREQ + p.wobblePhase) * WOBBLE_AMP
p.x += (p.vx + wobble) * dtSec
p.y += p.vy * dtSec
p.rotation += p.rotSpeed * dtSec

Gravity is the simplest force. GRAVITY is 1100f pixels per second squared, a constant downward acceleration added to vy every frame. Doubling it makes the confetti fall like wet napkins. Halving it gives a moon gravity float.

Drag is multiplicative. The factor exp(-AIR_DRAG * dtSec) is the closed form solution to exponential decay over a time step, which means the result is independent of frame rate. A particle loses the same fraction of speed per second whether the device renders at 60Hz or 120Hz. Plain vx *= 0.98f would bind drag to frame rate and drift between devices.

Wobble is a lateral perturbation. The expression sin(p.ageMs * WOBBLE_FREQ + p.wobblePhase) * WOBBLE_AMP produces a velocity in pixels per second that swings left and right with frequency WOBBLE_FREQ and amplitude WOBBLE_AMP. The random wobblePhase per particle decorrelates the swings, which is what sells the falling paper feel. Without the phase offset the whole burst would oscillate as one mass.

Finally, rotation += rotSpeed * dtSec advances the spin. Because rotSpeed is sampled in (-ROT_SPEED_MAX, +ROT_SPEED_MAX) at spawn, half the particles spin clockwise and half counter clockwise.

Drawing many particles in one Canvas pass

Rendering happens inside a single Canvas modifier. The body is one loop over the particle list:

@Suppress("UNUSED_EXPRESSION") frameNanos
for (p in particles) {
  val lifeFraction = (1f - p.ageMs / p.lifetimeMs).coerceIn(0f, 1f)
  val alpha = if (lifeFraction < FADE_OUT_FRACTION) {
    lifeFraction / FADE_OUT_FRACTION
  } else {
    1f
  }
  rotate(degrees = p.rotation, pivot = Offset(p.x, p.y)) {
    drawRect(
      color = p.color.copy(alpha = alpha),
      topLeft = Offset(p.x - p.w / 2f, p.y - p.h / 2f),
      size = Size(p.w, p.h),
    )
  }
}

The first line touches frameNanos. That read is the subscription that ties Canvas invalidation to the frame loop. Without it, the draw block would only run when its inputs nominally changed, and the particles would freeze.

The fade math is intentionally simple. lifeFraction runs from 1 at birth down to 0 at death. While it is above FADE_OUT_FRACTION (the last 30 percent of life), alpha stays at 1. Below that threshold, alpha ramps linearly to 0. The result is a particle that holds full color for most of its life, then dissolves at the end.

The rotation uses rotate(degrees, pivot) { ... } from DrawScope. This pushes a transform on the canvas, runs the inner block, and pops it. The pivot is the particle center, so each rectangle spins around itself and not around the canvas origin. Every particle shares the same outer draw scope, so there is no per particle composable, no per particle layout, and no per particle recomposition.

Triggering bursts on tap

Tap input is attached to the same Canvas via pointerInput:

detectTapGestures { offset ->
  val baseRad = Math.toRadians(LAUNCH_ANGLE_DEG.toDouble()).toFloat()
  val spreadRad = Math.toRadians(SPREAD_DEG.toDouble() / 2.0).toFloat()
  repeat(BURST_COUNT) {
    val angle = baseRad + (Random.nextFloat() * 2f - 1f) * spreadRad
    val speed = SPEED_MIN + Random.nextFloat() * (SPEED_MAX - SPEED_MIN)
    // ...build a Confetti and add it to particles
  }
}

detectTapGestures hands back the tap Offset in local coordinates of the Canvas. That offset becomes the spawn position of every particle in the burst. The launch angle is LAUNCH_ANGLE_DEG (-90f, which is straight up in screen coordinates because y grows downward), perturbed by a uniform random sample inside half of SPREAD_DEG. Speed, rotation, lifetime, color, and size jitter are all sampled from their ranges per particle.

Because the spawn loop runs BURST_COUNT (70) iterations and appends each new Confetti into the mutableStateListOf, the simulation loop sees them on the very next frame and starts integrating them. There is no separate spawn queue, no scheduling, just a list mutation that the draw loop will pick up.

Tweaking the storm

Every constant at the top of the composable changes the feel of the burst in a specific way.

  • BURST_COUNT sets how many particles spawn per tap. Higher counts feel grander, but every particle pays for itself in the per frame loop.
  • GRAVITY is the downward acceleration in pixels per second squared. At 200f the confetti drifts like it is on the moon, at 3000f it snaps to the ground.
  • SPEED_MIN and SPEED_MAX define the launch speed range. Wider ranges make the burst look less uniform.
  • SPREAD_DEG controls the cone of launch angles. Narrow values like 20f give a focused jet, wide values like 180f give an omnidirectional explosion.
  • LAUNCH_ANGLE_DEG sets the center direction of the cone. -90f is up, 0f is to the right.
  • AIR_DRAG is the exponential damping coefficient. At 0f particles keep their initial speed forever, at 4f they lose almost all horizontal motion within a second.
  • WOBBLE_AMP is the lateral flutter strength. Set it to zero and the confetti falls in straight ballistic arcs. Crank it up and pieces flutter sideways like real paper.
  • ROT_SPEED_MAX caps the absolute spin rate in degrees per second. Higher values make particles whirl into colorful streaks.
  • PALETTE is the list of colors sampled per particle. Replace it with your brand palette and the burst integrates instantly.

Conclusion

In this article, you've explored a confetti burst built on the two Compose primitives that survive when high level animation APIs run out of expressiveness. You walked through the Confetti data model, the withFrameNanos driven simulation loop, the gravity, drag, and wobble physics, the single Canvas pass that renders every particle, and the tap handler that spawns bursts at the touch location.

Understanding these internals helps you reason about where the cost of an animation lives and how to keep it bounded. One state write per frame, plain var fields on particles, frame rate independent damping via exp(-k * dt), and a clamped delta time give you a simulation that scales from a handful of particles to hundreds without redesigning the data flow. The same pattern applies to any system with many independent moving objects.

Whether you are building celebratory effects on a checkout screen, ambient background motion in a game, or tactile feedback for a productivity tool, this knowledge provides the foundation for writing particle systems in Compose that stay readable, deterministic, and fast.

As always, happy coding!

Jaewoong (skydoves)