← All articles

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:


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.

B. Triggered animation — “play this once when the trigger fires”

User taps something → animation plays out → settles. Shake, heart pop, success checkmark.

C. Continuous animation — “this animates forever while visible”

Pulsating border, traveling light, breathing dot, infinite loading spinner.

D. Static styling — “this is just a fixed visual”

Gradient border, custom shape, padding, fixed color.


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:

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

AnimationSpec for smoothness

Phase-aware modifier lambdas

Drawing on Path


8. Common mistakes

  1. 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.
  2. Using rememberInfiniteTransition on 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.
  3. Reading animated state in @Composable body when the same read could happen in drawBehind { } / graphicsLayer { }. The composition-phase read recomposes the whole subtree per animation frame.
  4. Animating background color via Modifier.background(color = animatedColor) — same composition-phase read problem. Use Modifier.drawBehind { drawRect(animatedColor) } instead.
  5. Using animateColorAsState inside a Modifier.Nodeanimate*AsState is @Composable. Inside a Node, use Animatable driven by coroutineScope.launch { animate(...) }.
  6. Restarting Node animations from update() unconditionally. Compare the param before restarting (if (node.durationMs != durationMs) etc) — otherwise every recomposition cancels and relaunches your animation.
  7. Forgetting onDetach cleanup for Node coroutines. coroutineScope auto-cancels, but explicit job?.cancel() guards detach/reattach races.
  8. 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


Already covered:

Practice still queued:

Future Compose topics that connect here:


11. Quick recall (10 bullets)

  1. Three animation categories: value-change, triggered, continuous. Each maps to a typical tool.
  2. Value-change: use animateColorAsState / animateFloatAsState / etc. No Node needed.
  3. Triggered: borderline — built-in Animatable + LaunchedEffect works at one site; Node wins if reused or in lists.
  4. Continuous: Node is the right tool. Animation coroutine in onAttach, cancelled in onDetach. Plain modifier API, lifecycle-correct, allocation-free in lists.
  5. Static styling (gradient, shape, padding): plain Modifier chain. Promoting to Node is the “share a Modifier chain” anti-pattern.
  6. Phase deferral is universal: read animated values inside drawBehind { } / graphicsLayer { } / layout { } lambdas, not in the @Composable body. One phase cheaper per frame.
  7. graphicsLayer cannot animate background color — use drawBehind { drawRect(animatedColor) } for that. graphicsLayer is for translation, rotation, scale, alpha.
  8. 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.
  9. 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.
  10. PathMeasure is the tool for path-traveling animations (light traveling around a border). setPath, length, getSegment(start, end, dest), getPosition(distance).