Mesh Aurora
Color orbs orbit on elliptical paths, additive blended into a living mesh gradient. Hue rotation cycles the entire palette over time.
Mesh gradients in design tools like Figma or Sketch are usually a static artifact. You drop a few color stops on a canvas, the renderer interpolates between them, and the result is baked into your asset. That smooth, multi color wash feels organic, but it never moves. The trick this animation uses is to skip the baking step entirely and assemble the gradient at runtime from a handful of moving radial brushes.
In this article, you'll explore how a small set of color orbs, each riding its own elliptical orbit, sum into a continuously shifting mesh gradient using withFrameNanos, Canvas, Brush.radialGradient, BlendMode.Plus, and HSV hue rotation. The orbs themselves are simple, the structure that emerges from their overlap is what makes the effect read as an aurora.
How the example is structured
The composable lays out a single BoxWithConstraints of fixed height, then draws a Canvas that fills it. A LaunchedEffect runs a withFrameNanos loop that advances a time counter every frame and bumps a tick state to invalidate the draw. The Canvas itself is wrapped in a graphicsLayer with CompositingStrategy.Offscreen so the additive blends compose against the layer rather than the screen behind it.
Inside the canvas, the scene is just a dark background plus ORB_COUNT translucent radial gradients. Each orb owns a randomized spec: a center point on the canvas, an orbit radius factor, an angular speed factor, a starting phase, an index into the palette, and a personal hue offset. The list is built once with a seeded Random(0xA0BEEFL) so the layout stays stable across recompositions.
private data class OrbSpec(
val centerX: Float,
val centerY: Float,
val orbitT: Float,
val angularT: Float,
val phase: Float,
val colorIndex: Int,
val hueSeed: Float,
)
The two Float fields named orbitT and angularT are not radians or pixels. They are normalized values between zero and one that get linearly interpolated into the actual orbit radius and angular speed ranges. Storing the spec this way lets you tune ORBIT_RADIUS_MIN_DP, ORBIT_RADIUS_MAX_DP, ANGULAR_SPEED_MIN, and ANGULAR_SPEED_MAX without regenerating the random orbs.
Parametric orbits with sin and cos
Every orb traces an ellipse around its own anchor point. The position update happens once per orb per frame inside the canvas draw block.
val orbitR = orbitMinPx + orb.orbitT * (orbitMaxPx - orbitMinPx).coerceAtLeast(0f)
val angularSpeed = ANGULAR_SPEED_MIN +
orb.angularT * (ANGULAR_SPEED_MAX - ANGULAR_SPEED_MIN).coerceAtLeast(0f)
val angle = orb.phase + angularSpeed * time
val px = orb.centerX + cos(angle) * orbitR
val py = orb.centerY + sin(angle) * orbitR * VERTICAL_SQUASH
The shape is the standard parametric ellipse: x = centerX + cos(angle) * a and y = centerY + sin\(angle\) * b, where a is orbitR and b is orbitR * VERTICAL_SQUASH. VERTICAL_SQUASH is 0.6f, which flattens the orbit so vertical motion is sixty percent of the horizontal span. A value of 1.0f would draw circles, 0.2f would create thin horizontal sweeps. The squash gives the aurora its lateral, sky like spread instead of looking like a field of pinwheels.
Two design choices stop the motion from looking mechanical. First, each orb has its own orbitR drawn from the ORBIT_RADIUS_MIN_DP to ORBIT_RADIUS_MAX_DP band, so some orbs make wide loops while others barely shift. Second, each orb has its own angularSpeed between ANGULAR_SPEED_MIN and ANGULAR_SPEED_MAX. With sixteen orbs picking independently from these ranges, the angles never line up. The phase offset randomized at construction time prevents every orb from starting at the same point on its ellipse, so the very first frame already shows a varied scene.
The integration is also frame rate independent. The withFrameNanos loop computes a delta in seconds and adds it to time, so angularSpeed * time always represents the same number of radians regardless of whether the device is rendering at sixty or one hundred twenty frames per second.
Hue cycling the palette
The five base colors in PALETTE are warm yellow, cyan, purple, pink, and aqua. If you draw them statically the scene reads as a pretty but fixed composition. The aurora effect comes from rotating each color through HSV space over time.
val baseColor = PALETTE[orb.colorIndex % PALETTE.size]
val shifted = shiftHue(baseColor, orb.hueSeed + time * HUE_ROTATION_SPEED)
The shiftHue helper converts the source color to HSV using android.graphics.Color.RGBToHSV, adds the requested degree offset to the hue channel, wraps the result into the zero to three sixty range, and converts back. Saturation and value stay untouched, so the color keeps its punch and only its hue drifts.
private fun shiftHue(c: Color, deg: Float): Color {
val hsv = FloatArray(3)
android.graphics.Color.RGBToHSV(
(c.red * 255\).toInt\(\), \(c.green * 255).toInt(), (c.blue * 255).toInt(), hsv,
)
hsv[0] = ((hsv[0] + deg) % 360f + 360f) % 360f
val rgb = android.graphics.Color.HSVToColor(hsv)
return Color(rgb).copy(alpha = c.alpha)
}
HUE_ROTATION_SPEED is 14f, which means each orb rotates fourteen degrees of hue per second. A full color cycle takes about twenty six seconds, slow enough that the change is felt rather than watched. The per orb hueSeed is randomized at construction, so two orbs that share a base palette color start from different points on the color wheel and never display the same hue at the same moment. This is what gives the gradient its painterly variation instead of a uniform color shift across the whole canvas.
The double modulo ((hsv[0] + deg) % 360f + 360f) % 360f handles negative inputs correctly. Kotlin's % returns a negative remainder for negative operands, so adding 360f before the second modulo guarantees a value in the proper range no matter how the hue drifts.
Additive blend mesh gradient
Each orb is drawn as a Brush.radialGradient with three stops: full color at the center, a translucent midpoint, and full transparency at the edge.
val brush = Brush.radialGradient(
colorStops = arrayOf(
0f to shifted,
MID_STOP to shifted.copy(alpha = MID_ALPHA),
1f to Color.Transparent,
),
center = Offset(px, py),
radius = glowRadiusPx,
)
drawCircle(
brush = brush,
radius = glowRadiusPx,
center = Offset(px, py),
blendMode = BlendMode.Plus,
)
MID_STOP is 0.42f and MID_ALPHA is 0.55f. Placing the half opacity stop before the geometric midpoint of the radius shapes the falloff so the bright core stays compact while the soft halo extends further out. A linear two stop gradient would look like a flashlight beam. The three stop curve looks more like fog catching light.
BlendMode.Plus is what turns a pile of separate gradients into a mesh. Plus blending sums the source and destination color channels, clamped to one. Two overlapping cyan halos add to a brighter cyan. A cyan halo over a magenta halo adds toward white. This is the same metaball intuition that lets two soft circles fuse into a single brighter blob: where they overlap, the math adds intensity instead of replacing it.
The reason the canvas needs CompositingStrategy.Offscreen on its graphicsLayer is that BlendMode.Plus blends against whatever is already in the destination buffer. Without an offscreen layer, the first orb would blend against the background color underneath the composable rather than against the previously drawn orbs and the dark BG_COLOR rect. Forcing an offscreen target gives the blend operations a controlled surface to accumulate into.
Tweaking orb count, glow, orbit radius, hue speed, palette
Each constant in the file maps to a specific visual lever, with the comments in the source listing the practical range for each one.
- ORB_COUNT: Currently
16. Two orbs gives you a sparse pair of glows that reads as two distinct lights. Twelve to sixteen produces the dense mesh look. Going higher quickly washes the canvas toward white because additive blending saturates fast. - ORB_GLOW_RADIUS_DP: Currently
70f. Small values like eighty produce crisp light spots with visible dark gaps. Pushing toward four hundred turns each orb into a screen filling wash and the individual orbits stop being readable. - ORBIT_RADIUS_MIN_DP and ORBIT_RADIUS_MAX_DP: Currently
30fto140f. Setting both to zero pins every orb to its anchor point, so the colors blend but never travel. A wide band like zero to two hundred makes some orbs sweep across the canvas while others barely twitch, which is what gives the effect its organic, layered motion. - HUE_ROTATION_SPEED: Currently
14fdegrees per second. Zero locks the palette in place and the scene becomes a pure motion piece. Sixty turns the canvas into a churning rainbow that loses its calm aurora character. - PALETTE: Currently five colors in the cyan, magenta, purple, pink, aqua family. Swapping in warmer greens and oranges produces a sunrise feel. A monochromatic palette of three blues plus two cyans pulls the mesh toward a deep ocean glow without changing any other constant.
VERTICAL_SQUASH, ANGULAR_SPEED_MIN, and ANGULAR_SPEED_MAX are secondary levers. Together they decide whether the aurora drifts horizontally like a real sky or churns in tighter spirals.
Conclusion
In this article, you've explored how a static mesh gradient concept becomes a living animation by composing a handful of moving radial brushes inside a single Canvas. The motion comes from per orb elliptical orbits driven by cos and sin, the color life comes from rotating each base color through HSV space, and the gradient itself emerges from BlendMode.Plus summing translucent halos on an offscreen layer.
Understanding the structure of this example helps you reason about other generative effects in Compose. The withFrameNanos loop with a delta time accumulator is the standard pattern for any time based animation that needs to outlast a single transition. The offscreen graphicsLayer is the recipe for any blend mode that needs to combine multiple draw calls into a self contained composition. The seeded Random plus a normalized OrbSpec is a portable way to make scenes that look hand placed without losing the ability to retune ranges later.
Whether you are building an animated splash screen, a mood backdrop for a meditation app, or a procedural texture for a game UI, the same three ingredients carry you a long way. Pick a small palette, give each element its own parametric path, and let an additive blend do the compositing for you. The result reads as a single rich surface even though every individual draw call is doing very little work.
As always, happy coding!
— Jaewoong (skydoves)