Animation Patterns: Which Tool Fits Which Animation
One-line definition: A practical, example-driven application of the modifier decision framework (built-in chain → lambda-form modifier → wrapper composable → Modifier.Node) specifically to animation patterns: value-change, triggered, and continuous animations. Includes phase-deferral techniques for animated values, the 4-ingredient parallax framework, and the wrapper-vs-component distinction that determines whether a composable actually adds a LayoutNode.
This file is the practice companion to:
compose-custom-modifiers.md(the how ofModifier.Node)compose-when-to-use-custom-modifiers.md(the when, with internals)
1. Why this exists
The decision framework says “promote to Modifier.Node when you need continuous animation / lifecycle / phase-targeted invalidation.” That rule is easy to recite but hard to apply at speed. This file walks through six real animation cases — pulsating border, traveling light, gradient border, color fade, parallax scroll, parallax tilt — and shows exactly which tool wins for each, with code.
The unifying insight: the kind of animation you’re doing predicts the right tool.
Animation kind Typical tool Example
───────────────── ────────────── ──────────────────
Value change Built-in `animate*AsState` Color fade on toggle
Triggered (one-shot) Built-in OR Node (depends) Heart pop, shake
Continuous (looping) Modifier.Node Pulsating border, traveling light
Static styling Built-in chain Gradient border
2. Three categories of animation, mapped to tools
A. Value-change animation — “from value A to value B, smoothly”
User toggles enabled. Color, size, alpha, or whatever smoothly tweens from one fixed value to another.
- Tool:
animateColorAsState,animateFloatAsState,animateIntAsState,animateDpAsState,animateOffsetAsState. - Phase concern: where you READ the resulting State decides whether it recomposes per frame.
- Read in
@Composablebody → composition invalidates per frame → 15–20 recompositions per animation. - Read in
Modifier.graphicsLayer { }lambda → only draw invalidates → 0 recompositions. - Read in
Modifier.drawBehind { }lambda → only draw invalidates → 0 recompositions.
- Read in
- Node needed? No. Built-in is the right choice.
B. Triggered animation — “play this once when the trigger fires”
User taps something → animation plays out → settles. Shake, heart pop, success checkmark.
- Built-in option:
Animatabledriven byLaunchedEffect(trigger). Works. - Wrapper option: a
@Composablewrapper that takes content. Costs one extra LayoutNode. - Node option:
Modifier.x(trigger: Int)withupdate()detecting trigger change and restarting the animation. Costs nothing extra in LayoutNodes, packages as a plain chainable modifier, allocation-free in lists. - Node needed? Borderline. If used at one site, built-in is fine. If reused, Node wins.
C. Continuous animation — “this animates forever while visible”
Pulsating border, traveling light, breathing dot, infinite loading spinner.
- Built-in option:
rememberInfiniteTransition+Modifier.drawBehind { }. Works on a single screen. - Wrapper option: same as above but requires a
@Composablewrapper. Costs one extra LayoutNode. - Node option: animation coroutine in
onAttach, cancelled inonDetach. Plain modifier API. Lifecycle-correct: stops when scrolled off-screen or in NavHost back stack. Battery-correct. - Node needed? Yes, for any continuous animation that’s reused or that should respect attachment lifecycle.
D. Static styling — “this is just a fixed visual”
Gradient border, custom shape, padding, fixed color.
- Tool:
Modifier.border(),Brush.linearGradient(), etc. Chained built-ins. - Node needed? No. Anti-pattern to use a Node here (“share a Modifier chain” anti-pattern).
3. Phase deferral for animations — the universal performance technique
For value-change animations, the cost is dominated by where you read the animated state.
// ❌ Composition-phase read — invalidates composition per frame
.background(color = animatedColor) // animatedColor read in @Composable body
// ✓ Draw-phase read — invalidates only draw per frame
.drawBehind { drawRect(color = animatedColor) } // animatedColor read inside lambda
// ✓ Layout-phase read — invalidates only layout per frame
.offset { IntOffset(animatedX.roundToPx(), 0) } // animatedX read inside lambda
// ✓ Draw-phase transform — for translation/rotation/scale/alpha
.graphicsLayer { translationY = animatedY } // animatedY read inside lambda
The pattern: put the read inside the lambda passed to a phase-aware modifier. The lambda body runs in that phase; reads inside it tag that phase.
Phase cheat sheet:
| Modifier lambda | Read phase | Use for |
|---|---|---|
Modifier.drawBehind { } |
Draw | Custom drawing with animated values |
Modifier.drawWithCache { } |
Draw, with cached objects | Drawing where some setup can be cached |
Modifier.graphicsLayer { } |
Draw | translation, rotation, scale, alpha, shadowElevation |
Modifier.layout { } |
Layout | Animated placement / size |
Modifier.offset { } |
Layout | Animated translation that affects layout |
graphicsLayer is usually the cheapest because it applies a GPU transform without relayout. Use it for any visual transform (move/rotate/scale/fade).
4. The 4-ingredient parallax framework
Any parallax animation breaks down into four decisions. Same framework for scroll-parallax, tilt-parallax, pointer-parallax.
1. INPUT — what value changes? (scroll, sensor, time, pointer)
2. LAYERS — which elements move? what is each one's depth_factor?
3. TRANSFORM — input × depth maps to what? (translationY? rotationX? scale?)
4. PHASE — composition / layout / draw? (almost always draw via graphicsLayer)
Core formula:
visual_movement = input × depth_factor
depth_factor = 0.0 → locked to viewport (no movement)
depth_factor = 0.5 → half-speed (looks farther away)
depth_factor = 1.0 → moves at full input speed (at the surface)
depth_factor > 1.0 → moves faster than input (pops out)
For scroll parallax inside a verticalScroll Column, the image is INSIDE the scrolling container. Its slot moves up by scrollState.value. To slow it down, apply an offsetting translation:
// Option A — Layout-phase (slightly heavier; re-runs layout + draw)
fun Modifier.parallaxScroll(scrollState: ScrollState, rate: Int) =
layout { m, c ->
val p = m.measure(c)
layout(p.width, p.height) { p.place(0, scrollState.value / rate) }
}
// Option B — Draw-phase (preferred; only re-runs draw)
fun Modifier.parallaxScroll(scrollState: ScrollState, depth: Float = 0.5f) =
graphicsLayer { translationY = -scrollState.value * depth }
Both produce identical pixels. graphicsLayer is strictly cheaper for pure visual parallax. The layout { } form is only worth choosing when the parallax position must drive hit-testing or sibling layout.
Neither needs a Modifier.Node — they’re stateless transformations of an external input.
Tilt parallax (gyroscope-driven) is the one that genuinely benefits from a Node, because the sensor subscription needs onAttach/onDetach lifecycle for battery correctness.
5. Worked code examples
5a. Continuous: pulsating border (Modifier.Node)
fun Modifier.pulsatingBorder(
color: Color = Color.Red,
width: Dp = 4.dp,
cornerRadius: Dp = 8.dp,
durationMs: Int = 1000,
): Modifier = this then PulsatingBorderElement(color, width, cornerRadius, durationMs)
private data class PulsatingBorderElement(
val color: Color,
val width: Dp,
val cornerRadius: Dp,
val durationMs: Int,
) : ModifierNodeElement<PulsatingBorderNode>() {
override fun create() = PulsatingBorderNode(color, width, cornerRadius, durationMs)
override fun update(node: PulsatingBorderNode) {
node.color = color
node.width = width
node.cornerRadius = cornerRadius
if (node.durationMs != durationMs) {
node.durationMs = durationMs
node.restart()
}
}
}
private class PulsatingBorderNode(
var color: Color,
var width: Dp,
var cornerRadius: Dp,
var durationMs: Int,
) : Modifier.Node(), DrawModifierNode {
private var alpha by mutableFloatStateOf(0.2f)
private var job: Job? = null
override fun onAttach() { startAnimation() }
fun restart() { job?.cancel(); startAnimation() }
private fun startAnimation() {
job = coroutineScope.launch {
while (isActive) {
animate(0.2f, 1f, animationSpec = tween(durationMs)) { v, _ -> alpha = v }
animate(1f, 0.2f, animationSpec = tween(durationMs)) { v, _ -> alpha = v }
}
}
}
override fun ContentDrawScope.draw() {
drawContent()
drawRoundRect(
color = color.copy(alpha = alpha),
style = Stroke(width = width.toPx()),
cornerRadius = CornerRadius(cornerRadius.toPx()),
)
}
override fun onDetach() { job?.cancel(); job = null }
}
5b. Continuous + path math: traveling light border (Modifier.Node + PathMeasure)
fun Modifier.travelingBorderLight(
color: Color = Color(0xFF00E5FF),
strokeWidth: Dp = 3.dp,
cornerRadius: Dp = 12.dp,
trailFraction: Float = 0.25f,
durationMs: Int = 1500,
): Modifier = this then TravelingBorderElement(color, strokeWidth, cornerRadius, trailFraction, durationMs)
private data class TravelingBorderElement(
val color: Color, val strokeWidth: Dp, val cornerRadius: Dp,
val trailFraction: Float, val durationMs: Int,
) : ModifierNodeElement<TravelingBorderNode>() {
override fun create() = TravelingBorderNode(color, strokeWidth, cornerRadius, trailFraction, durationMs)
override fun update(node: TravelingBorderNode) {
node.color = color
node.strokeWidth = strokeWidth
node.cornerRadius = cornerRadius
node.trailFraction = trailFraction
if (node.durationMs != durationMs) {
node.durationMs = durationMs
node.restart()
}
}
}
private class TravelingBorderNode(
var color: Color, var strokeWidth: Dp, var cornerRadius: Dp,
var trailFraction: Float, var durationMs: Int,
) : Modifier.Node(), DrawModifierNode {
private var progress by mutableFloatStateOf(0f)
private var job: Job? = null
override fun onAttach() { startAnimation() }
fun restart() { job?.cancel(); startAnimation() }
private fun startAnimation() {
job = coroutineScope.launch {
while (isActive) {
animate(0f, 1f, animationSpec = tween(durationMs, easing = LinearEasing)) { v, _ ->
progress = v
}
}
}
}
override fun ContentDrawScope.draw() {
drawContent()
val cornerPx = cornerRadius.toPx()
val borderPath = Path().apply {
addRoundRect(RoundRect(Rect(Offset.Zero, size), CornerRadius(cornerPx, cornerPx)))
}
val measure = PathMeasure().apply { setPath(borderPath, forceClosed = true) }
val totalLen = measure.length
val trailLen = totalLen * trailFraction
val headLen = progress * totalLen
val tailLen = headLen - trailLen
val trail = Path()
if (tailLen >= 0f) {
measure.getSegment(tailLen, headLen, trail, true)
} else {
measure.getSegment(0f, headLen, trail, true)
measure.getSegment(totalLen + tailLen, totalLen, trail, true)
}
drawPath(
path = trail,
color = color.copy(alpha = 0.6f),
style = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Round),
)
// Bright head dot
val headPos = measure.getPosition(headLen)
drawCircle(color = color, radius = strokeWidth.toPx() * 1.8f, center = headPos)
drawCircle(color = color.copy(alpha = 0.3f), radius = strokeWidth.toPx() * 3.5f, center = headPos)
}
override fun onDetach() { job?.cancel(); job = null }
}
5c. Static: gradient border (plain extension)
fun Modifier.gradientBorder(
width: Dp = 2.dp,
colors: List<Color> = listOf(Color.Blue, Color(0xFFFF9800)),
shape: Shape = RoundedCornerShape(12.dp),
): Modifier = this.border(width = width, brush = Brush.linearGradient(colors), shape = shape)
Direction variants — pass to Brush.linearGradient:
- Top-to-bottom: default
- Left-to-right:
Brush.horizontalGradient(colors) - Diagonal:
Brush.linearGradient(colors, start = Offset.Zero, end = Offset.Infinite) - Multi-stop:
Brush.linearGradient(0f to Color.A, 0.5f to Color.B, 1f to Color.C) - Sweep / rainbow ring:
Brush.sweepGradient(colors)
5d. Value-change: smooth color fade with phase-deferred read
Naive version (composition-phase read — ~15–20 recompositions per fade):
val animatedColor by animateColorAsState(if (enabled) Color.Blue else Color.Gray)
Button(
onClick = { ... },
colors = ButtonDefaults.buttonColors(containerColor = animatedColor),
) { Text("Click") }
Optimized version (draw-phase read — 0 recompositions during fade):
@Composable
fun FadingButton(
text: String,
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabledColor: Color = Color.Blue,
disabledColor: Color = Color.Gray,
animationSpec: AnimationSpec<Color> = tween(500, easing = FastOutSlowInEasing),
) {
val animatedColor by animateColorAsState(
targetValue = if (enabled) enabledColor else disabledColor,
animationSpec = animationSpec,
label = "buttonColor",
)
Box(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.drawBehind { drawRect(color = animatedColor) } // ← draw-phase read
.clickable(enabled = enabled, onClick = onClick)
.heightIn(min = 48.dp)
.padding(horizontal = 24.dp, vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(text, color = Color.White, fontSize = 16.sp)
}
}
For the truly minimal version (1 LayoutNode, just Text), apply all modifiers directly to the Text:
Text(
text = text,
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.drawBehind { drawRect(animatedColor) }
.clickable(enabled = enabled, onClick = onClick)
.heightIn(min = 48.dp)
.padding(horizontal = 24.dp, vertical = 12.dp),
)
This works only for single-element content (no icon-plus-text).
6. The wrapper-vs-component distinction (important)
Common confusion: “doesn’t every wrapping composable add an extra LayoutNode?” — only wrappers do, not new components.
Wrapper case (adds LayoutNode): Component case (no extra node):
───────────────────────────── ───────────────────────────────
@Composable @Composable
fun ShakeWrapper(content) { fun FadingButton(text) {
Box(modifier = ...) { Box(modifier = ...) {
content() ← already a LayoutNode Text(text) ← part of this component
} }
} }
Before: 1 node Before: 0 nodes
After: 2 nodes (+1 wrapper Box) After: 2 nodes (Box + Text)
Verdict: extra node Verdict: no wrapping happened
A composable adds an extra LayoutNode when it wraps content the caller already had. A composable does NOT add an extra LayoutNode when it builds a self-contained UI element from scratch.
For comparison: Material Button internally is Surface + Row + content text = ~3 nodes. A custom Box + Text button = 2 nodes. Custom is leaner.
7. Key APIs
Animation drivers
animateColorAsState,animateFloatAsState,animateIntAsState,animateDpAsState,animateOffsetAsState— value-change tweens between two fixed values.Animatable<T, V>— manually-driven animation; you callanimateTo/snapTo. Use on aModifier.Nodefor animation-without-recomposition.rememberInfiniteTransition— composable-scoped infinite animation. Use for one-off continuous animation on a single screen; promote to Node if reused.animate(initial, target, spec) { v, vel -> ... }— top-level coroutine-friendly animation, used inside Node coroutines.
AnimationSpec for smoothness
tween(durationMillis, easing)— predictable duration; pair withFastOutSlowInEasing,LinearEasing,EaseInOutCubic.spring(dampingRatio, stiffness)— physics-based.Spring.DampingRatioLowBouncy+Spring.StiffnessLow= smooth, deliberate.
keyframes { ... }— for non-monotonic / multi-step animations.infiniteRepeatable(animation, repeatMode)— loop a base spec;RepeatMode.Reversefor ping-pong.
Phase-aware modifier lambdas
Modifier.drawBehind { drawRect(...) }— draw-phase reads.Modifier.drawWithCache { onDrawBehind { ... } }— draw-phase reads with cached setup.Modifier.graphicsLayer { translationX = ...; alpha = ... }— draw-phase transforms.Modifier.layout { measurable, constraints -> ... }— layout-phase placement.Modifier.offset { IntOffset(x, y) }— layout-phase translation.
Drawing on Path
Path— geometry container;addRoundRect,addOval,lineTo, etc.PathMeasure— walk along a path;setPath,length,getSegment(start, end, dest, ...),getPosition(distance),getTangent(distance).
8. Common mistakes
- Reaching for Modifier.Node for a static gradient border. “Anti-pattern 2 — Node to share a Modifier chain.” Use an extension function over built-ins.
- Using
rememberInfiniteTransitionon every list item that needs a continuous animation. Each item allocates its own InfiniteTransition + Animatable. Promote to a Node where the per-item Element is equality-checked and the coroutine is attachment-bound. - Reading animated state in
@Composablebody when the same read could happen indrawBehind { }/graphicsLayer { }. The composition-phase read recomposes the whole subtree per animation frame. - Animating background color via
Modifier.background(color = animatedColor)— same composition-phase read problem. UseModifier.drawBehind { drawRect(animatedColor) }instead. - Using
animateColorAsStateinside a Modifier.Node —animate*AsStateis@Composable. Inside a Node, useAnimatabledriven bycoroutineScope.launch { animate(...) }. - Restarting Node animations from
update()unconditionally. Compare the param before restarting (if (node.durationMs != durationMs)etc) — otherwise every recomposition cancels and relaunches your animation. - Forgetting
onDetachcleanup for Node coroutines.coroutineScopeauto-cancels, but explicitjob?.cancel()guards detach/reattach races. - Confusing the wrapper-vs-component case — assuming every composable that emits a Box adds an extra LayoutNode. The rule applies only to wrappers around pre-existing content.
9. Best practices
- Match the animation category to the right tool:
- Value change →
animate*AsState - Triggered, one-off →
Animatable+LaunchedEffect, or Node if reused - Continuous → Node
- Static → built-in extension
- Value change →
- For any animated value, push the read down a phase. State read in
@Composablebody → invalidates composition. Move todrawBehind/graphicsLayer/layoutlambdas. graphicsLayer { }is the default for visual transforms (translation, rotation, scale, alpha). Cheapest phase, no relayout.drawBehind { }is the default for animated backgrounds/borders/colors. Reads animated colors without recomposing.- For continuous animations in lists, always promote to a Node. The lifecycle (attachment-bound) is correct by default and Element equality gives free skipping.
- For smooth feel, prefer
tween(durationMillis, easing = FastOutSlowInEasing)over the defaultspring(). Springs are tight; tweens give you control. - For static gradients / shapes / decorations, return a plain Modifier chain from an extension function. Never wrap in a Node.
10. Related concepts / what to learn next
Already covered:
compose-custom-modifiers.md— the how ofModifier.Node.compose-when-to-use-custom-modifiers.md— the when, plus internals (two trees, slot table, three phases, attachment).compose-component-identity.md.compose-modifiers-vs-single-child-wrappers.md.compose-measure-phase.md.
Practice still queued:
- Heart pop — triggered scale animation;
graphicsLayer { scaleX/scaleY }withAnimatable. - Three-dot loading indicator — continuous animation with multiple phase-shifted dots; Node.
- Progress bar with shine — external state (progress) combined with internal continuous animation (shine sweep); Node for the shine.
- Animated counter —
animateIntAsState, value-change.
Future Compose topics that connect here:
DelegatingNode— composing Nodes inside Nodes (reuse pattern for “all my buttons should have pulsing borders”).PointerInputModifierNode— gesture-driven animations.LookaheadScope— pre-measurement for shared-element transitions.Modifier.animateContentSize— automatic size animation when content changes.
11. Quick recall (10 bullets)
- Three animation categories: value-change, triggered, continuous. Each maps to a typical tool.
- Value-change: use
animateColorAsState/animateFloatAsState/ etc. No Node needed. - Triggered: borderline — built-in
Animatable + LaunchedEffectworks at one site; Node wins if reused or in lists. - Continuous: Node is the right tool. Animation coroutine in
onAttach, cancelled inonDetach. Plain modifier API, lifecycle-correct, allocation-free in lists. - Static styling (gradient, shape, padding): plain Modifier chain. Promoting to Node is the “share a Modifier chain” anti-pattern.
- Phase deferral is universal: read animated values inside
drawBehind { }/graphicsLayer { }/layout { }lambdas, not in the@Composablebody. One phase cheaper per frame. graphicsLayercannot animate background color — usedrawBehind { drawRect(animatedColor) }for that.graphicsLayeris for translation, rotation, scale, alpha.- Parallax = input × depth_factor. Four ingredients: input, layers, transform, phase. The visual effect uses built-ins (
graphicsLayer); a Node enters only if the input source (sensor) needs lifecycle management. - Wrapper vs component: a composable adds an extra LayoutNode only when it wraps content the caller already had. A self-contained component (FadingButton, Card) doesn’t “add” a node — it IS the node.
PathMeasureis the tool for path-traveling animations (light traveling around a border).setPath,length,getSegment(start, end, dest),getPosition(distance).