← Back to list

Compose Performance Part 8: Animations Without Lag The Complete Compose Animation Toolkit…

Compose has the most powerful animation system of any UI framework I’ve ever used. It’s also the easiest to misuse. The same APIs that let…

Ramadan Sayed · 2026-08-01 13:30 · 0 claps · 10.4 min read paywalled
#jetpack-compose #animation #jetpack-compose-animation
Open on Medium ↗
Wiki topics: SAF · Safety & Alignment 🎬 · Film & Television

Compose Performance Part 8: Animations Without Lag The Complete Compose Animation Toolkit, animate*AsState vs Animatable vs updateTransition vs rememberInfiniteTransition, Spring vs Tween vs Keyframes Performance, the graphicsLayer Animation Trick, Avoiding Composition During Animation, AnimatedVisibility and AnimatedContent Internals, and the Patterns That Keep 60fps Even With Complex Animated UIs

Compose has the most powerful animation system of any UI framework I’ve ever used. It’s also the easiest to misuse. The same APIs that let you write beautiful spring-physics interactions in 10 lines of code can also make your app drop frames every time something animates. The difference isn’t usually the animation itself — it’s where you read the animated state, what phase the animation triggers, and whether you’ve accidentally pinned the work to the most expensive part of the rendering pipeline.

In Part 4 we covered the deferred read pattern — the foundational trick for animation performance. In Part 5 we covered the state primitives. Now we put it all together with the complete animation toolkit: every API Compose offers for animation, when to use each one, and the patterns that scale to complex UIs with many simultaneous animations.

By the end of this article you’ll know how to write any animation in Compose that runs at 60fps on a mid-tier device. You’ll know when to reach for animate*AsState, when to graduate to Animatable, when updateTransition is the right tool, and how to avoid the traps that make even simple animations destroy your frame rate.

Part 1: The Animation Toolkit at a Glance

Compose offers a tiered animation API. Use the simplest one that works.

TIER 1 — Single-value animations:
  animate*AsState        — animate one value (Float, Dp, Color, etc.)
  Used 80% of the time. Simple to use, decent performance.

TIER 2 - Imperative control:
  Animatable             - manual control over a single value
  Use when you need to interrupt, queue, or coordinate animations.
TIER 3 - Coordinated state-based:
  updateTransition       - multiple animations tied to one state change
  Use for "expand panel" style animations with multiple coordinated values.
TIER 4 - Infinite animations:
  rememberInfiniteTransition - looping animations
  Use for spinners, pulses, ambient effects.
TIER 5 - Composition transitions:
  AnimatedVisibility    - enter/exit animations for showing/hiding
  AnimatedContent       - content swap animations
TIER 6 - Manual:
  Custom Animation<T>   - full control via Animation/Spec APIs
  Rarely needed. Use when nothing else fits.

Each tier has different performance characteristics. Let’s go through them.

Part 2: animate*AsState — The Simple Default

The most common Compose animation API:

val backgroundColor by animateColorAsState(
    targetValue = if (selected) Color.Blue else Color.Gray
)

val padding by animateDpAsState(
    targetValue = if (expanded) 24.dp else 8.dp
)
val scale by animateFloatAsState(
    targetValue = if (pressed) 0.95f else 1f,
    animationSpec = spring(stiffness = Spring.StiffnessHigh)
)

How It Works

animate*AsState returns a State<T> that smoothly animates toward targetValue. When targetValue changes, the animation starts. The State emits a new value every animation frame (60+ fps).

The Performance Reality

Every time the State emits a new value, anything reading it gets scheduled for recomposition. If you read the animated value in your composable body, you recompose 60 times per second during the animation.

// ❌ Recomposes 60 times/sec
@Composable
fun Button(pressed: Boolean) {
    val scale by animateFloatAsState(if (pressed) 0.95f else 1f)
    Box(modifier = Modifier.scale(scale))  // ← state read in composition
}

// ✅ Doesn't recompose
@Composable
fun Button(pressed: Boolean) {
    val scale by animateFloatAsState(if (pressed) 0.95f else 1f)
    Box(modifier = Modifier.graphicsLayer {
        scaleX = scale
        scaleY = scale
    })  // ← state read in draw phase
}

This is the most important animation performance pattern. Move the animated state read out of the composable body and into a graphicsLayer { } or lambda modifier.

The Variants

animateFloatAsState(...)   // Float
animateDpAsState(...)      // Dp
animateColorAsState(...)   // Color
animateIntAsState(...)     // Int
animateIntOffsetAsState(...) // IntOffset
animateOffsetAsState(...)  // Offset
animateSizeAsState(...)    // Size
animateRectAsState(...)    // Rect
animateValueAsState<T>(...) // Generic — provide TwoWayConverter

For custom types, provide a TwoWayConverter:

val MoneyConverter = TwoWayConverter<Money, AnimationVector1D>(
    convertToVector = { AnimationVector1D(it.amount.toFloat()) },
    convertFromVector = { Money(it.value.toDouble(), Currency.USD) }
)

val animatedMoney by animateValueAsState(
    targetValue = targetMoney,
    typeConverter = MoneyConverter
)

Part 3: Animatable — Imperative Control

When you need control beyond declarative animate*AsState, use Animatable.

@Composable
fun DraggableBox() {
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()

Box(
        modifier = Modifier
            .graphicsLayer { translationX = offsetX.value }
            .pointerInput(Unit) {
                detectDragGestures(
                    onDragEnd = {
                        scope.launch {
                            offsetX.animateTo(0f, spring())
                        }
                    }
                ) { change, dragAmount ->
                    scope.launch {
                        offsetX.snapTo(offsetX.value + dragAmount.x)
                    }
                }
            }
    )
}

Animatable API

// Starting state
val anim = remember { Animatable(initialValue = 0f) }

// Run animation (suspending)
anim.animateTo(targetValue = 100f, animationSpec = spring())
anim.animateTo(50f, tween(durationMillis = 300))
// Instant jump (no animation)
anim.snapTo(100f)
// Interrupt running animation
anim.stop()
// Reset to initial value
anim.snapTo(0f)
// Read current value
val current = anim.value
// Read target value (where it's animating to)
val target = anim.targetValue
// Is animating right now?
val running = anim.isRunning

Why Use Animatable Over animate*AsState

USE Animatable WHEN:
  ✅ You need to interrupt and redirect animations
  ✅ You need to queue multiple animations
  ✅ You need to track when animation completes
  ✅ You need to integrate with gestures/drag
  ✅ You need to start animations from non-composition contexts

USE animate*AsState WHEN:
  ✅ Single value, single target, fire-and-forget
  ✅ Reactive to a state change
  ✅ Don't need to know completion

Async Composition

Animatable.animateTo() is a suspend function. You call it from a coroutine:

val offsetX = remember { Animatable(0f) }
var clicked by remember { mutableStateOf(false) }

LaunchedEffect(clicked) {
    if (clicked) {
        // Sequential animations
        offsetX.animateTo(100f, tween(200))
        offsetX.animateTo(-100f, tween(200))
        offsetX.animateTo(0f, spring())
    }
}

This is a more powerful pattern than animate*AsState for complex sequences.

Part 4: updateTransition — Coordinated Multi-Value Animations

When one state change should drive multiple coordinated animations, use updateTransition:

enum class CardState { Collapsed, Expanded }

@Composable
fun ExpandableCard(state: CardState) {
    val transition = updateTransition(state, label = "card")
    val backgroundColor by transition.animateColor(label = "bg") { s ->
        when (s) {
            CardState.Collapsed -> Color.Gray
            CardState.Expanded -> Color.Blue
        }
    }
    val cornerRadius by transition.animateDp(label = "corner") { s ->
        when (s) {
            CardState.Collapsed -> 12.dp
            CardState.Expanded -> 0.dp
        }
    }
    val padding by transition.animateDp(label = "padding") { s ->
        when (s) {
            CardState.Collapsed -> 16.dp
            CardState.Expanded -> 24.dp
        }
    }
    Box(
        modifier = Modifier
            .clip(RoundedCornerShape(cornerRadius))
            .background(backgroundColor)
            .padding(padding)
    )
}

Why updateTransition Is Better

You could write this with three separate animate*AsState calls — but updateTransition synchronizes them. They all start together, finish together, and use the same animation spec by default. Visual coherence is automatic.

Performance Notes

Each transition.animate* returns a State<T>. Same performance characteristics as animate*AsState — read them in lambda modifiers or graphicsLayer for best performance.

Part 5: rememberInfiniteTransition — Looping Animations

For pulsing, breathing, ambient animations:

@Composable
fun PulseDot() {
    val infiniteTransition = rememberInfiniteTransition(label = "pulse")

val scale by infiniteTransition.animateFloat(
        initialValue = 1f,
        targetValue = 1.3f,
        animationSpec = infiniteRepeatable(
            animation = tween(800, easing = LinearOutSlowInEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "scale"
    )
    Box(
        modifier = Modifier
            .size(20.dp)
            .graphicsLayer { scaleX = scale; scaleY = scale }
            .background(Color.Red, CircleShape)
    )
}

Critical Performance Caveat

Infinite transitions never stop. As long as the composable is in composition, the animation runs and emits state changes every frame.

Multiple PulseDots on screen = multiple infinite animations. Each is reading state, scheduling redraws.

Mitigation:

  1. Use graphicsLayer to keep work in draw phase
  2. Pause animations when not visible:
@Composable
fun PulseDot(isVisible: Boolean) {
    val infiniteTransition = rememberInfiniteTransition()
    val scale = remember { Animatable(1f) }

LaunchedEffect(isVisible) {
        if (isVisible) {
            // Start animation
            while (isActive) {
                scale.animateTo(1.3f, tween(800))
                scale.animateTo(1f, tween(800))
            }
        } else {
            scale.snapTo(1f)  // Stop
        }
    }
    // ...
}

The Animatable + LaunchedEffect pattern gives you more control over starting/stopping than rememberInfiniteTransition.

Part 6: AnimatedVisibility — Enter/Exit Animations

For showing/hiding content with transitions:

@Composable
fun NotificationBanner(visible: Boolean) {
    AnimatedVisibility(
        visible = visible,
        enter = slideInVertically { -it } + fadeIn(),
        exit = slideOutVertically { -it } + fadeOut()
    ) {
        Notification()
    }
}

Built-in Enter/Exit Variants

// Slides
slideInVertically()
slideInHorizontally()
slideOutVertically()
slideOutHorizontally()

// Fades
fadeIn()
fadeOut()
// Scales
scaleIn()
scaleOut()
// Expand/shrink (good for collapsible content)
expandVertically()
expandHorizontally()
shrinkVertically()
shrinkHorizontally()

Combine with +:

enter = slideInVertically { -it } + fadeIn() + scaleIn(initialScale = 0.95f)

Performance Notes

AnimatedVisibility uses graphicsLayer internally for fade/scale. Slides use translation. These are draw-phase operations — efficient.

Expand/shrink animations affect layout — they’re more expensive because they re-measure each frame. Avoid expand/shrink in long lists.

Part 7: AnimatedContent — Content Swap Animations

For animating between different content based on state:

@Composable
fun TabContent(selectedTab: Tab) {
    AnimatedContent(
        targetState = selectedTab,
        transitionSpec = {
            slideInHorizontally { it } + fadeIn() togetherWith
            slideOutHorizontally { -it } + fadeOut()
        }
    ) { tab ->
        when (tab) {
            Tab.HOME -> HomeContent()
            Tab.PROFILE -> ProfileContent()
            Tab.SETTINGS -> SettingsContent()
        }
    }
}

The previous content slides out left while new content slides in right. Smooth tab transitions.

Animating Just Text Changes

@Composable
fun Counter(count: Int) {
    AnimatedContent(
        targetState = count,
        transitionSpec = {
            if (targetState > initialState) {
                slideInVertically { it } togetherWith slideOutVertically { -it }
            } else {
                slideInVertically { -it } togetherWith slideOutVertically { it }
            }
        }
    ) { value ->
        Text("$value")
    }
}

Numbers slide up when incrementing, down when decrementing. Beautiful for counters, prices, scores.

Performance Notes

AnimatedContent keeps both old and new content composed during the transition. For heavy content, this can be expensive. Use sparingly.

Part 8: Animation Specs — Spring vs Tween vs Keyframes

The animationSpec parameter controls the timing curve. Each has performance characteristics.

Spring

animationSpec = spring(
    dampingRatio = Spring.DampingRatioMediumBouncy,
    stiffness = Spring.StiffnessMedium
)

Spring physics-based animations. Natural-feeling. Cost: math per frame. Negligible.

DAMPING RATIOS (more bounce → less):
  DampingRatioHighBouncy
  DampingRatioMediumBouncy
  DampingRatioLowBouncy
  DampingRatioNoBouncy (default — smooth)

STIFFNESS (faster → slower):
  StiffnessHigh        (~150ms)
  StiffnessMediumLow
  StiffnessMedium      (~300ms - default)
  StiffnessLow
  StiffnessVeryLow     (~700ms)

Tween (Time-Based)

animationSpec = tween(
    durationMillis = 300,
    delayMillis = 0,
    easing = FastOutSlowInEasing
)

Linear progression over time. Predictable duration. Slightly cheaper than spring (no physics math).

Keyframes

animationSpec = keyframes {
    durationMillis = 500
    0.dp at 0          // Start at 0
    20.dp at 100      // Jump to 20
    -10.dp at 300     // Back to -10
    0.dp at 500       // End at 0
}

Multi-stage tweens. Useful for complex paths.

Performance Differences

All three specs have similar per-frame costs. The differences in performance come from:

  • What you’re animating (layout-affecting vs draw-only)
  • How many animations are running simultaneously
  • Whether you’ve deferred state reads

Pick specs based on UX (spring for natural, tween for precise) — performance is similar.

Part 9: graphicsLayer — The Animation Performance Superpower

We covered this in Part 4. Let me reiterate because it’s THAT important.

Anything that’s a visual transform — translation, rotation, scale, alpha, shadow — should animate through graphicsLayer:

@Composable
fun FlyInBox(visible: Boolean) {
    val offsetX = remember { Animatable(0f) }
    val opacity = remember { Animatable(0f) }

LaunchedEffect(visible) {
        if (visible) {
            launch { offsetX.animateTo(0f) }
            launch { opacity.animateTo(1f) }
        } else {
            launch { offsetX.animateTo(-300f) }
            launch { opacity.animateTo(0f) }
        }
    }
    Box(
        modifier = Modifier
            .graphicsLayer {
                translationX = offsetX.value
                alpha = opacity.value
            }
    ) { /* ... */ }
}

The composable body runs ONCE. Two simultaneous animations. Both run in draw phase. GPU handles it. Multiple such elements on screen = no performance impact.

Things You SHOULD Animate in graphicsLayer

  • translationX, translationY
  • scaleX, scaleY
  • rotationX, rotationY, rotationZ
  • alpha
  • shadowElevation
  • cameraDistance

Things You CANNOT Animate in graphicsLayer

  • Padding (changes layout)
  • Margin (changes layout)
  • Size (changes layout)
  • Background color (use drawBehind + animated color)
  • Border (use drawBehind)

For animations that affect layout, you can’t avoid layout phase. Minimize them.

Part 10: A Complete Animation Performance Pattern

Putting it all together:

@Composable
fun AnimatedHeroSection(
    state: HeroState,
    modifier: Modifier = Modifier
) {
    // Single source of truth — drives multiple animations
    val transition = updateTransition(state, label = "hero")

val scale by transition.animateFloat(label = "scale") { s ->
        when (s) {
            HeroState.Collapsed -> 0.8f
            HeroState.Expanded -> 1.0f
            HeroState.Featured -> 1.1f
        }
    }
    val alpha by transition.animateFloat(label = "alpha") { s ->
        when (s) {
            HeroState.Collapsed -> 0.6f
            HeroState.Expanded -> 1f
            HeroState.Featured -> 1f
        }
    }
    val cornerRadius by transition.animateDp(label = "corner") { s ->
        when (s) {
            HeroState.Collapsed -> 24.dp
            HeroState.Expanded -> 12.dp
            HeroState.Featured -> 0.dp
        }
    }
    Box(
        modifier = modifier
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                alpha = alpha
            }
            .clip(RoundedCornerShape(cornerRadius))
    ) {
        HeroContent()
    }
}

This animation:

  • Uses updateTransition to coordinate three values
  • Reads transform values in graphicsLayer (draw phase)
  • Only cornerRadius affects layout (the clip shape change)
  • Switches between three states smoothly
  • Composable body runs ONCE — animation cost is minimal

Part 11: Avoiding Common Animation Pitfalls

Pitfall 1: Reading Animated State in Composable Body

We covered this. Pattern: Modifier.scale(animatedValue)Modifier.graphicsLayer { scaleX = animatedValue }.

Pitfall 2: Animating Inside Lists Without Donut Hole

// ❌ Each item's animated state causes its parent (LazyColumn) to recompose
LazyColumn {
    items(items) { item ->
        AnimatedItem(item)
    }
}

Wait — actually LazyColumn is already smart enough to isolate items. The issue is if your item composable’s structure depends on animated state in a way that’s not optimized. Always use graphicsLayer for transforms inside list items.

Pitfall 3: Triggering Animations from Non-Composition Triggers

// ❌ Animation doesn't trigger correctly
var trigger by mutableStateOf(false)

LaunchedEffect(Unit) {
    delay(1000)
    trigger = true  // Sets state - animation should trigger
}
val alpha by animateFloatAsState(if (trigger) 1f else 0f)

This works, but the LaunchedEffect runs only on first composition. If you want recurring triggers, structure differently:

LaunchedEffect(someState) {
    snapshotFlow { someState.condition }
        .distinctUntilChanged()
        .collect { /* trigger animation */ }
}

Pitfall 4: Animating Heavyweight Composables

// ❌ Animating a list of 1,000 items
val height by animateDpAsState(if (expanded) 600.dp else 100.dp)
Box(modifier = Modifier.height(height)) {
    LazyColumn { items(1000) { /* ... */ } }
}

This re-measures the LazyColumn every animation frame. Significant work.

Better: animate the visibility of a clipped container, not the size of a list.

Pitfall 5: Forgetting to Stop Infinite Animations

// ❌ Animation runs forever even when not visible
@Composable
fun PulseIndicator() {
    val infiniteTransition = rememberInfiniteTransition()
    val scale by infiniteTransition.animateFloat(...)
    // ...
}

When this composable goes off-screen (scrolled out, navigated away), the animation keeps running. Use Animatable with LaunchedEffect for explicit lifecycle control.

Part 12: A Production Animation Checklist

□ Animated state read in graphicsLayer or lambda modifier, NOT composable body
□ Transforms (scale, translate, rotate) use graphicsLayer
□ Colors animated via drawBehind (when possible) instead of background()
□ Layout-affecting animations (size, padding) used sparingly
□ Infinite animations have lifecycle control (start/stop on visibility)
□ Multi-value coordinated animations use updateTransition
□ AnimatedVisibility used for enter/exit transitions
□ AnimatedContent used for content swaps
□ Spring/tween chosen based on UX, not performance (they're similar)
□ Animation count on a single screen is reasonable (< 20 simultaneous)
□ Tested with multiple simultaneous animations on mid-tier device
□ Layout Inspector confirms no excessive recomposition during animation

Conclusion

Animations in Compose are powerful but unforgiving. The animation APIs are simple — animateFloatAsState, Animatable, updateTransition — but the difference between smooth 60fps animation and dropped frames comes down to where you read the animated state.

The takeaways:

  1. **animate*AsState for simple, declarative animations** — 80% of cases
  2. **Animatable for imperative control** — drags, interrupts, queues
  3. **updateTransition for coordinated multi-value animations** from one state change
  4. **rememberInfiniteTransition for ambient effects** — but consider Animatable for lifecycle control
  5. **AnimatedVisibility for enter/exit, AnimatedContent for content swaps**
  6. **graphicsLayer is the secret weapon** — transforms animated here are GPU-native
  7. Read animated state in lambda modifiers / graphicsLayer, never in composable body
  8. Spring vs Tween is a UX choice, not a performance one — both are similar cost

In Part 9 we go deeper into your specific use case — Many Animations Coordinated. Multiple animations on a single screen, choreographed sequences, staggered animations, and the patterns that keep them all running smoothly.

What’s Next

Part 9: Many Animations Coordinated — Multiple Simultaneous Animations Without Killing Frame Rate, Staggered Choreography, Reading vs Writing State During Animation, Lifting Animation State vs Scoping It, and the Patterns for Dashboards Where Everything Animates at Once.

Connect with Me on LinkedIn

Follow me on LinkedIn

Tags: #JetpackCompose #ComposeAnimation #ComposePerformance #AndroidPerformance #AndroidDev #Kotlin #GraphicsLayer #60fps


메타데이터
post_id
ee083cbeeccc
slug
compose-performance-part-8-animations-without-lag-the-complete-compose-animation-toolkit-ee083cbeeccc
url
https://medium.com/@ramadan123sayed/compose-performance-part-8-animations-without-lag-the-complete-compose-animation-toolkit-ee083cbeeccc
canonical_url
https://medium.com/@ramadan123sayed/compose-performance-part-8-animations-without-lag-the-complete-compose-animation-toolkit-ee083cbeeccc
author_url
https://medium.com/@ramadan123sayed
status
ok
fetched_at
2026-09-05 07:17:27