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…
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:
- Use
graphicsLayerto keep work in draw phase - 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,translationYscaleX,scaleYrotationX,rotationY,rotationZalphashadowElevationcameraDistance
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
updateTransitionto coordinate three values - Reads transform values in
graphicsLayer(draw phase) - Only
cornerRadiusaffects 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:
**animate*AsStatefor simple, declarative animations** — 80% of cases**Animatablefor imperative control** — drags, interrupts, queues**updateTransitionfor coordinated multi-value animations** from one state change**rememberInfiniteTransitionfor ambient effects** — but consider Animatable for lifecycle control**AnimatedVisibilityfor enter/exit,AnimatedContentfor content swaps****graphicsLayeris the secret weapon** — transforms animated here are GPU-native- Read animated state in lambda modifiers / graphicsLayer, never in composable body
- 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