Radial FAB Menu
A floating action button that explodes into satellite buttons along an arc. Per index stagger delays unfurl and refold the menu.
A floating action button menu looks like a UI flourish, but it sits at the intersection of layout, physics, and choreography. The radial layout in particular is just polar coordinates rendered through Compose, with each satellite button placed by a cos and sin pair around a center anchor. Per item Animatable instances let each satellite carry its own progress, and a LaunchedEffect launches them with a stagger delay so they unfold one after another. When the menu closes, the same machinery runs in reverse and the satellites refold back into the FAB.
In this article, you'll explore how to compose a radial FAB menu, including how polar coordinates translate ARC_DEG and ITEM_COUNT into satellite positions, how a list of Animatable values gives each satellite an independent progress, how LaunchedEffect schedules staggered launches with coroutineScope.launch and delay, and how each tweakable constant changes the feel of the animation.
How the example is structured
The composable is built around three layers stacked in a single Box. At the bottom sits a Column providing the title and a hint label. Inside that column lives a fixed height Box that acts as the stage for the menu. The center of that stage holds the FAB itself, a 72.dp circle that toggles isOpen on tap. Around that center, the satellites render before the FAB so the FAB always paints on top.
Each satellite is a 56.dp circle with a colored background and a glyph from a small palette. Position, scale, and opacity are all driven by a per item Animatable<Float, AnimationVector1D> whose value sweeps from 0f to 1f. When the value is 0f, the satellite sits at the center, scaled down, and fully transparent. When it reaches 1f, the satellite arrives at its target position on the arc, full size, and fully opaque.
The constants that govern the choreography sit at the top of the function:
val ITEM_COUNT = 4 // try 3 (sparse) ↔ 8 (crowded)
val RADIUS_DP = 110f // distance from center
val STAGGER_MS = 50L // delay between satellites
val SPRING_STIFFNESS = 500f // 100 (lazy) ↔ 1500 (snappy)
val SPRING_DAMPING = 0.55f // 0.3 (very bouncy) ↔ 1.0 (no overshoot)
val ARC_DEG = 180f // 180 = half circle, 360 = full circle
These six numbers are the entire surface area for tuning. Everything else is derived from them.
Computing satellite positions with polar coordinates
Each satellite needs a final (x, y) offset from the center anchor. Polar coordinates make this straightforward: pick an angle on the circle, multiply the unit direction (cos, sin) by the radius, and you have a Cartesian translation. The example does exactly that inside the per item loop:
val angleDeg = if (ITEM_COUNT == 1) {
90f
} else {
val sweepStart = 180f + (180f - ARC_DEG) / 2f
sweepStart + (i / (ITEM_COUNT - 1f)) * ARC_DEG
}
val rad = Math.toRadians(angleDeg.toDouble())
val targetX = (radiusPx * cos(rad)).toFloat()
val targetY = (radiusPx * sin(rad)).toFloat()
The angle math deserves a close read. sweepStart lines the arc up so it always sits centered above the FAB. With ARC_DEG = 180f, the expression reduces to 180f, which means the first satellite starts at the leftmost point of a half circle. As ARC_DEG shrinks, the sweep narrows and sweepStart shifts inward to keep the arc centered. The fraction i / (ITEM_COUNT - 1f) walks from 0f to 1f across the satellites, so the angles distribute evenly along the chosen arc.
Math.toRadians converts to radians because Kotlin's cos and sin work in radians, not degrees. The radius is converted from dp to pixels once, outside the loop, using LocalDensity so the layout stays density correct on every device.
Inside graphicsLayer, the per item progress v scales the target position so the satellite slides outward as the value grows:
.graphicsLayer {
val v = p.value
translationX = targetX * v
translationY = targetY * v
val s = 0.4f + 0.6f * v
scaleX = s
scaleY = s
alpha = v
}
Multiplying the target by v produces a smooth interpolation from the center to the arc position. Scale eases in from 0.4f to 1f so the satellites grow as they fly out, and alpha ties straight to the progress so they fade in as well.
A list of Animatables for staggered choreography
A single Animatable would force every satellite to share one timeline. Staggered choreography needs each satellite to advance independently, so the example holds a list:
val progresses = remember(ITEM_COUNT) {
List(ITEM_COUNT) { Animatable(0f) }
}
remember(ITEM_COUNT) keys the list to ITEM_COUNT, so changing the count rebuilds the list with the right number of entries instead of leaking stale state. Each entry is an Animatable<Float, AnimationVector1D> initialized to 0f, matching the closed state of the menu.
Because each satellite owns its own Animatable, a coroutine can launch its animation at any time without affecting the others. That independence is what enables both the open delay sequence and the close sequence to play correctly even if the user toggles the menu rapidly. When isOpen flips again, each Animatable simply animates from its current value toward the new target, so satellites caught mid flight gracefully redirect.
LaunchedEffect with delays per item
The choreography sits inside a LaunchedEffect keyed on isOpen. Whenever the open state changes, the effect cancels any in flight launches and starts a fresh round:
LaunchedEffect(isOpen) {
progresses.forEachIndexed { i, p ->
launch {
val staggerIdx = if (isOpen) i else ITEM_COUNT - 1 - i
delay(staggerIdx * STAGGER_MS)
p.animateTo(
targetValue = if (isOpen) 1f else 0f,
animationSpec = spring(
stiffness = SPRING_STIFFNESS,
dampingRatio = SPRING_DAMPING,
),
)
}
}
}
Three things happen here that are worth pointing out.
First, the loop calls launch for every satellite. LaunchedEffect provides a CoroutineScope, and launch inside it produces concurrent child coroutines. Each child handles exactly one satellite. If the effect is cancelled because isOpen flipped again, the structured concurrency rules cancel every child coroutine for free.
Second, staggerIdx flips depending on direction. When opening, satellites animate in their natural order so the first one leaves the FAB first. When closing, the order reverses so the last satellite to arrive is the first to retreat. That symmetry makes the open and close motions feel like mirror images.
Third, the delay(staggerIdx * STAGGER_MS) step is the entire stagger mechanism. With STAGGER_MS = 50L, satellite 0 launches immediately, satellite 1 waits 50 ms, satellite 2 waits 100 ms, and so on. Setting STAGGER_MS = 0L makes them fire simultaneously, which produces a tight burst rather than an unfurl.
The spring spec then takes over. With stiffness 500f and damping 0.55f, the satellites overshoot slightly and settle, giving the menu a tactile bounce.
Tweaking item count, radius, stagger, spring, arc
The six constants give you direct control over the menu's character.
ITEM_COUNT: Sets how many satellites participate. Three feels sparse and elegant. Eight crowds the arc and turns the menu into a fan. Becauseprogressesis keyed on this value, changing it rebuilds theAnimatablelist cleanly.RADIUS_DP: Distance from the FAB to each satellite. Smaller values keep the menu compact and quick to scan. Larger values produce a dramatic sweep, but watch for satellites overflowing the stage.STAGGER_MS: The delay between satellite launches.0Lfires every satellite at once for a synchronized burst.50Lto120Lgives the rolling unfurl effect. Beyond that, the trailing satellites feel disconnected from the head of the wave.SPRING_STIFFNESS: Higher stiffness reaches the target faster.100ffeels lazy and floaty.1500fsnaps with little visible travel time. The default500flands in the responsive but visible range.SPRING_DAMPING: Controls overshoot and oscillation.0.3fproduces visible bouncing as the satellite passes its target and returns.1.0fremoves overshoot entirely. The default0.55fshows one gentle bounce, which feels lively without becoming distracting.ARC_DEG: Defines how wide the satellites spread.180fproduces a clean half circle above the FAB.360fwraps the satellites completely around. Smaller values like90fproduce a tight quarter fan, useful when the FAB sits near a screen corner.
The FAB itself uses animateFloatAsState to rotate the + glyph by 45 degrees when open, turning it into an x. That rotation reuses the same SPRING_STIFFNESS and SPRING_DAMPING, so the FAB and satellites share the same physical feel.
Conclusion
In this article, you've explored how to build a radial FAB menu that explodes its satellites along an arc with staggered spring physics. You saw how polar coordinates turn an angle and a radius into a Cartesian translation, how a list of Animatable instances lets each satellite carry its own progress, and how LaunchedEffect plus coroutineScope.launch schedules per item delays that produce the unfurl and refold motions.
Understanding these internals helps you reach for the same pattern any time you need choreographed, per index motion. The combination of an Animatable list, a LaunchedEffect keyed on the trigger state, and launch plus delay inside the loop is the same structure you would use for a staggered list reveal, a sequenced toolbar reveal, or a coordinated chart entry animation. Once you internalize that pattern, you stop reaching for ad hoc timer code and start composing motion declaratively.
Whether you're building a media controls overlay, implementing a contextual action menu over a map, or designing a creative tool palette, this knowledge provides the foundation for radial interactions that feel alive. The math is simple, the Compose primitives carry the rest, and the result is a menu that responds to taps with physical character rather than abrupt state changes.
As always, happy coding!
— Jaewoong (skydoves)