Skip to content
All posts
August 23, 202610 min read

Jetpack Compose Animations: A Practical Playbook

A hands-on guide to building smooth, performant animations in Jetpack Compose, covering animate*AsState, AnimatedContent, Animatable, and gesture-driven animations with real production patterns from 22+ shipped Android apps.

AndroidKotlinJetpack ComposeUX
Share:

Animations in Jetpack Compose are deceptively simple to start and painfully hard to master. The APIs are powerful, but the real challenge is knowing which tool to reach for and when. After building and maintaining 22+ Android apps solo, I've learned that the wrong animation API can turn a 60fps delight into a janky mess.

This playbook is the reference I wish I had when I first started. It covers the four animation primitives I use every day in production, with real code, real trade-offs, and real mistakes I've made so you don't have to.

The Animation API Landscape

Before writing a single line of animation code, you need to understand what tools Compose gives you. Picking the wrong one is the most common mistake I see in code reviews and open-source projects.

APIBest ForTriggerState Ownership
code
animate*AsState
Simple value transitionsState changeAutomatic
code
AnimatedContent
Composable swapsState changeAutomatic
code
Animatable
Fine-grained controlManualYou manage it
code
updateTransition
Coordinated multi-propertyState changeShared
code
rememberInfiniteTransition
Looping animationsNone (continuous)Automatic

The golden rule: start with the simplest API that solves your problem. If

code
animateColorAsState
works, do not reach for
code
Animatable
. Complexity compounds fast when you are a solo developer maintaining 22 codebases.

Building Blocks: animate*AsState and AnimatedContent

These are your bread and butter. I use them in every single app I ship. They handle composition, cancellation, and lifecycle automatically, which means fewer bugs and less boilerplate.

animate*AsState for Property Transitions

When a state value changes and you want a smooth visual transition,

code
animate*AsState
is your first stop. It supports
code
Dp
,
code
Color
,
code
Float
,
code
Int
,
code
Offset
,
code
Rect
,
code
IntOffset
, and
code
IntSize
out of the box.

kotlin
@Composable
fun ExpandableCard(
    title: String,
    content: String,
    isExpanded: Boolean,
    onToggle: () -> Unit
) {
    val cardElevation by animateDpAsState(
        targetValue = if (isExpanded) 12.dp else 2.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        ),
        label = "cardElevation"
    )

    val arrowRotation by animateFloatAsState(
        targetValue = if (isExpanded) 180f else 0f,
        animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
        label = "arrowRotation"
    )

    val contentAlpha by animateFloatAsState(
        targetValue = if (isExpanded) 1f else 0f,
        animationSpec = tween(durationMillis = 200),
        label = "contentAlpha"
    )

    Card(
        elevation = CardDefaults.cardElevation(defaultElevation = cardElevation),
        modifier = Modifier.fillMaxWidth().clickable { onToggle() }
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(text = title, style = MaterialTheme.typography.titleMedium)
                Icon(
                    imageVector = Icons.Default.KeyboardArrowDown,
                    contentDescription = if (isExpanded) "Collapse" else "Expand",
                    modifier = Modifier.rotate(arrowRotation)
                )
            }
            if (isExpanded) {
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = content,
                    style = MaterialTheme.typography.bodyMedium,
                    modifier = Modifier.alpha(contentAlpha)
                )
            }
        }
    }
}

Notice the

code
label
parameter on every animation. This is not optional in my codebases. It identifies the animation in the Layout Inspector and animation debug tooling. When you are debugging a janky screen at midnight, those labels save you hours.

[!TIP] Always pass a descriptive

code
label
to every
code
animate*AsState
call. The Compose Layout Inspector uses these labels to surface animation overdraw and unnecessary recompositions. Without labels, you are debugging blind.

AnimatedContent for State Transitions

When the content itself changes, not just a property,

code
AnimatedContent
handles the enter and exit transitions for you. This is what I use for tab switches, step indicators, and loading states.

kotlin
@Composable
fun LoadingStateContainer(
    uiState: UiState,
    content: @Composable () -> Unit,
    loading: @Composable () -> Unit,
    error: @Composable (String) -> Unit
) {
    AnimatedContent(
        targetState = uiState,
        transitionSpec = {
            when {
                initialState is UiState.Loading && targetState is UiState.Success ->
                    fadeIn(animationSpec = tween(300)) togetherWith
                    fadeOut(animationSpec = tween(150))
                initialState is UiState.Error && targetState is UiState.Loading ->
                    slideInHorizontally { it } togetherWith
                    slideOutHorizontally { -it }
                else ->
                    fadeIn(tween(200)) togetherWith fadeOut(tween(200))
            }
        },
        label = "loadingStateTransition"
    ) { state ->
        when (state) {
            is UiState.Loading -> loading()
            is UiState.Error -> error(state.message)
            is UiState.Success -> content()
        }
    }
}

[!WARNING]

code
AnimatedContent
renders both the old and new composables simultaneously during the transition. If your content is heavy, such as a large
code
LazyColumn
with complex items, this doubles memory and compute pressure during the transition window. Profile it before shipping.

Fine-Grained Control with Animatable

When you need to drive an animation from a gesture, sync multiple values, or interrupt an in-progress animation,

code
animate*AsState
is not enough. You need
code
Animatable
.

code
Animatable
gives you a coroutine-driven animation that you can start, stop, and snap to values programmatically. This is the API I use for drag-to-dismiss, pull-to-refresh, and any animation that reacts to touch.

kotlin
@Composable
fun SwipeToDismissCard(
    onDismiss: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    val offsetX = remember { Animatable(0f) }
    val scope = rememberCoroutineScope()
    val density = LocalDensity.current
    val dismissThreshold = with(density) { 200.dp.toPx() }

    Box(
        modifier = modifier
            .fillMaxWidth()
            .pointerInput(Unit) {
                detectHorizontalDragGestures(
                    onDragEnd = {
                        scope.launch {
                            if (offsetX.value.abs() > dismissThreshold) {
                                offsetX.animateTo(
                                    targetValue = if (offsetX.value > 0) offsetX.value * 3f else offsetX.value * 3f,
                                    animationSpec = tween(durationMillis = 200, easing = FastOutLinearInEasing)
                                )
                                onDismiss()
                                offsetX.snapTo(0f)
                            } else {
                                offsetX.animateTo(
                                    targetValue = 0f,
                                    animationSpec = spring(
                                        dampingRatio = Spring.DampingRatioMediumBouncy,
                                        stiffness = Spring.StiffnessMedium
                                    )
                                )
                            }
                        }
                    }
                ) { _, dragAmount ->
                    scope.launch {
                        offsetX.snapTo(offsetX.value + dragAmount)
                    }
                }
            }
            .offset { IntOffset(offsetX.value.roundToInt(), 0) }
    ) {
        content()
    }
}

The key difference here is that

code
Animatable
gives you a
code
suspend
API. You call
code
animateTo
inside a coroutine scope, which means you can await it, compose it with other suspend functions, and cancel it. This is critical for gesture-driven animations where the user can interrupt at any moment.

[!NOTE]

code
Animatable
is thread-safe. You can call
code
animateTo
from any coroutine without worrying about race conditions. The internal
code
Mutex
ensures only one animation runs at a time, and new calls interrupt the previous one cleanly.

Coordinating Multiple Properties with updateTransition

When a single state change drives multiple animated properties,

code
updateTransition
keeps them in sync without managing multiple
code
Animatable
instances. I use this for button state machines: idle, loading, success, error.

kotlin
@Composable
fun SubmitButton(
    buttonState: ButtonState,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    val transition = updateTransition(targetState = buttonState, label = "buttonTransition")

    val buttonColor by transition.animateColor(
        transitionSpec = { tween(300) },
        label = "buttonColor"
    ) { state ->
        when (state) {
            ButtonState.Idle -> MaterialTheme.colorScheme.primary
            ButtonState.Loading -> MaterialTheme.colorScheme.surfaceVariant
            ButtonState.Success -> Color(0xFF4CAF50)
            ButtonState.Error -> MaterialTheme.colorScheme.error
        }
    }

    val buttonWidth by transition.animateDp(
        transitionSpec = { spring(stiffness = Spring.StiffnessMedium) },
        label = "buttonWidth"
    ) { state ->
        when (state) {
            ButtonState.Loading -> 48.dp
            else -> 200.dp
        }
    }

    val iconAlpha by transition.animateFloat(
        transitionSpec = { tween(200) },
        label = "iconAlpha"
    ) { state ->
        if (state == ButtonState.Success || state == ButtonState.Error) 1f else 0f
    }

    Button(
        onClick = onClick,
        colors = ButtonDefaults.buttonColors(containerColor = buttonColor),
        modifier = modifier.animateContentSize(animationSpec = tween(300))
    ) {
        if (buttonState == ButtonState.Loading) {
            CircularProgressIndicator(
                modifier = Modifier.size(24.dp),
                color = MaterialTheme.colorScheme.onSurfaceVariant,
                strokeWidth = 2.dp
            )
        } else {
            Row(
                horizontalArrangement = Arrangement.Center,
                verticalAlignment = Alignment.CenterVertically
            ) {
                if (iconAlpha > 0f) {
                    Icon(
                        imageVector = if (buttonState == ButtonState.Success) Icons.Default.Check else Icons.Default.Close,
                        contentDescription = null,
                        modifier = Modifier
                            .padding(end = 8.dp)
                            .alpha(iconAlpha)
                    )
                }
                Text(
                    text = when (buttonState) {
                        ButtonState.Idle -> "Submit"
                        ButtonState.Loading -> ""
                        ButtonState.Success -> "Done"
                        ButtonState.Error -> "Retry"
                    }
                )
            }
        }
    }
}

The advantage of

code
updateTransition
is that all properties animate from the same state change. You do not need to manage individual animation lifecycles. The transition object coordinates everything, and you can even share it across composables using
code
rememberTransition
.

Performance: Keeping Animations at 60fps

Animations are worthless if they jank. After shipping 22+ apps, I have a checklist I run through for every animated screen.

What Causes Jank

CauseImpactFix
Animating layout properties (width, height)Triggers re-measure on every frameAnimate transform instead (offset, scale)
Heavy composition in AnimatedContentDouble allocation during transitionSimplify content or use crossfade
Running animations on the main thread during I/OFrame dropsMove I/O to
code
Dispatchers.IO
Missing
code
key
in
code
AnimatedContent
Unnecessary recompositionAlways provide stable keys
Animating
code
alpha
on a large view tree
OverdrawAnimate visibility or use
code
graphicsLayer

The biggest mistake I see is animating layout properties. When you animate

code
width
or
code
height
, Compose must re-measure the entire subtree on every frame. When you animate
code
offset
or
code
scale
, Compose only updates the draw layer, which is a GPU operation.

kotlin
// BAD: triggers re-measure every frame
val animatedWidth by animateDpAsState(targetValue = if (expanded) 200.dp else 100.dp)
Box(modifier = Modifier.width(animatedWidth))

// GOOD: only updates the draw layer
val animatedScale by animateFloatAsState(targetValue = if (expanded) 1f else 0.5f)
Box(modifier = Modifier.graphicsLayer { scaleX = animatedScale; scaleY = animatedScale })

[!IMPORTANT] Never animate layout properties when you can animate transform properties instead.

code
graphicsLayer
is your best friend for scale, rotation, alpha, and translation. It operates entirely on the GPU and never triggers re-measure or re-layout.

Using graphicsLayer for Complex Animations

code
graphicsLayer
is a modifier that gives you a hardware-accelerated layer for transformations. It is the single most important tool for keeping animations smooth.

kotlin
@Composable
fun PressableCard(
    isPressed: Boolean,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    val scale by animateFloatAsState(
        targetValue = if (isPressed) 0.95f else 1f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessHigh
        ),
        label = "pressScale"
    )

    Card(
        modifier = modifier.graphicsLayer {
            scaleX = scale
            scaleY = scale
            shadowElevation = if (isPressed) 4f else 8f
            shape = RoundedCornerShape(16.dp)
            clip = true
        }
    ) {
        content()
    }
}

The

code
shadowElevation
property inside
code
graphicsLayer
is particularly useful. It lets you animate shadows without triggering re-layout, which is impossible with the standard
code
elevation
parameter on
code
Card
.

[!WARNING] When using

code
graphicsLayer
with
code
shadowElevation
, you must also set
code
shape
and
code
clip = true
inside the lambda. Without a shape, the shadow renders as a rectangle regardless of your composable's actual shape. This is a subtle bug that looks fine on square elements but breaks on rounded corners.

Infinite Animations Without the Memory Tax

code
rememberInfiniteTransition
is perfect for continuous animations like shimmer effects, loading spinners, and breathing indicators. But it comes with a cost: it runs forever, even when your composable is not visible.

kotlin
@Composable
fun ShimmerBox(
    modifier: Modifier = Modifier
) {
    val transition = rememberInfiniteTransition(label = "shimmer")
    val translateX by transition.animateFloat(
        initialValue = -300f,
        targetValue = 300f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 1200, easing = LinearEasing),
            repeatMode = RepeatMode.Restart
        ),
        label = "shimmerTranslate"
    )

    Box(
        modifier = modifier
            .clip(RoundedCornerShape(8.dp))
            .background(
                brush = Brush.linearGradient(
                    colors = listOf(
                        Color(0xFFB0B0B0).copy(alpha = 0.2f),
                        Color(0xFFB0B0B0).copy(alpha = 0.4f),
                        Color(0xFFB0B0B0).copy(alpha = 0.2f)
                    ),
                    start = Offset(translateX, 0f),
                    end = Offset(translateX + 300f, 0f)
                )
            )
    )
}

The problem is that this animation runs even when the composable is scrolled off-screen in a

code
LazyColumn
. Compose does not automatically pause infinite animations for off-screen items. The fix is to gate the animation on visibility.

kotlin
@Composable
fun SmartShimmerBox(
    isVisible: Boolean,
    modifier: Modifier = Modifier
) {
    if (!isVisible) {
        Box(modifier = modifier.clip(RoundedCornerShape(8.dp)).background(Color.Gray.copy(alpha = 0.2f)))
        return
    }

    val transition = rememberInfiniteTransition(label = "smartShimmer")
    val translateX by transition.animateFloat(
        initialValue = -300f,
        targetValue = 300f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 1200, easing = LinearEasing),
            repeatMode = RepeatMode.Restart
        ),
        label = "smartShimmerTranslate"
    )

    Box(
        modifier = modifier
            .clip(RoundedCornerShape(8.dp))
            .background(
                brush = Brush.linearGradient(
                    colors = listOf(
                        Color(0xFFB0B0B0).copy(alpha = 0.2f),
                        Color(0xFFB0B0B0).copy(alpha = 0.4f),
                        Color(0xFFB0B0B0).copy(alpha = 0.2f)
                    ),
                    start = Offset(translateX, 0f),
                    end = Offset(translateX + 300f, 0f)
                )
            )
    )
}

By checking

code
isVisible
before creating the infinite transition, you ensure the animation only runs when the user can actually see it. In a
code
LazyColumn
, you can derive
code
isVisible
from the
code
LazyListState
layout info.

Key Takeaways

  • Choose the simplest animation API first. Reach for

    code
    animate*AsState
    for property transitions,
    code
    AnimatedContent
    for composable swaps, and only escalate to
    code
    Animatable
    when you need gesture-driven or coroutine-controlled animations. Premature complexity in animation code is a tax you pay every time you touch that screen.

  • Never animate layout properties when transform properties will do. Use

    code
    graphicsLayer
    for scale, rotation, alpha, and translation. It runs on the GPU and never triggers re-measure. Animating
    code
    width
    or
    code
    height
    is the most common cause of jank I see in production Compose code.

  • Always label your animations. The

    code
    label
    parameter is your debugging lifeline. The Layout Inspector surfaces animation

Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this