Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 066 / 100

Animations in Compose: a practical Android & Kotlin guide

Motion that helps users.

Expona Admin8 min read

Animations in Compose: a practical Android & Kotlin guide

Motion in user interfaces isn't decoration—it's communication. When you tap a button and it smoothly scales, or a sheet slides up from the bottom, your brain processes that motion as feedback that something happened. In Jetpack Compose, animations aren't afterthoughts bolted onto static layouts; they're first-class citizens built into the composable model. Over the course of this 100-day Modern Android & Kotlin series, we've built screens, managed state, and handled events. Now we make those screens feel alive.

Compose gives us a toolkit for animations that's both powerful and approachable. Whether you need to animate a single value, show or hide content smoothly, or coordinate multiple properties at once, the framework handles the timing, interpolation, and frame dispatch so you can focus on what matters: making your users feel like the app responds to them. Let's walk through the practical patterns you'll use every day.

animate*AsState — Animate single values easily

The simplest animation in Compose starts with animateFloatAsState, animateIntAsState, animateDpAsState, and their siblings. These functions take a target value and return a state that smoothly transitions from the current value to the target. No timers, no frame callbacks, no manual interpolation.

Picture a common scenario: a user taps a "like" button and you want the icon to scale up slightly, then settle back. Here's how it looks:

kotlin
@Composable
fun LikeButton(isLiked: Boolean, onClick: () -> Unit) {
    val scale by animateFloatAsState(
        targetValue = if (isLiked) 1.2f else 1f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        ),
        label = "likeScale"
    )
    
    IconButton(
        onClick = onClick,
        modifier = Modifier.scale(scale)
    ) {
        Icon(
            imageVector = if (isLiked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
            contentDescription = "Like",
            tint = if (isLiked) Color.Red else Color.Gray
        )
    }
}

When isLiked changes, the scale state automatically animates. The animationSpec parameter lets you choose how that motion feels: a spring for bouncy, natural movement; a tween for fixed-duration animations; an inertia for physics-based momentum. The label parameter is metadata for debugging and profiling—always include it.

This pattern works for any animatable type: colors, sizes, positions, alphas. Common specs include:

  • spring(dampingRatio, stiffness) — Natural, bouncy feel. Dampingratios closer to 1.0 are stiffer; lower values bounce more.
  • tween(durationMillis, delayMillis, easing) — Fixed-duration with linear, ease-in, ease-out, or custom curves.
  • snap(delayMillis) — Instant change, optionally delayed. Useful for discrete state changes.

Keep animateFloatAsState for single-value changes. When you need to animate multiple properties in lockstep, move to the next tool.

AnimatedVisibility — Animate appearing and disappearing content

Visibility changes are ubiquitous: error messages fade in, loading spinners vanish, detail panels slide open. AnimatedVisibility wraps a composable and handles enter and exit animations declaratively.

Imagine a validation error that should slide in from the top and fade:

kotlin
@Composable
fun EmailField(email: String, onEmailChange: (String) -> Unit) {
    var showError by remember { mutableStateOf(false) }
    
    Column {
        TextField(
            value = email,
            onValueChange = { newEmail ->
                onEmailChange(newEmail)
                showError = newEmail.isNotEmpty() && !isValidEmail(newEmail)
            },
            label = { Text("Email") }
        )
        
        AnimatedVisibility(
            visible = showError,
            enter = slideInVertically(initialOffsetY = { -40 }) + fadeIn(),
            exit = slideOutVertically(targetOffsetY = { -40 }) + fadeOut()
        ) {
            Text(
                "Invalid email format",
                color = Color.Red,
                modifier = Modifier.padding(top = 8.dp)
            )
        }
    }
}

AnimatedVisibility combines enter and exit animations. You can stack multiple effects with +: slideInVertically() + fadeIn() slides and fades simultaneously. Other enter transitions include slideInHorizontally, expandVertically, and scaleIn. Exit transitions mirror these.

The real power emerges when you animate entire screens or panels. In a ride-hailing app, when a user confirms a ride, a bottom sheet summarizing the trip should slide up smoothly, not pop in:

kotlin
@Composable
fun RideConfirmation(isVisible: Boolean, rideDetails: RideDetails?) {
    AnimatedVisibility(
        visible = isVisible,
        enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
        exit = slideOutVertically(targetOffsetY = { it }) + fadeOut()
    ) {
        if (rideDetails != null) {
            ConfirmationSheet(rideDetails)
        }
    }
}

slideInVertically(initialOffsetY = { it }) starts the sheet at the bottom (height offset). As the animation progresses, it slides to its final position. This creates the illusion of content emerging from off-screen.

Transitions — updateTransition for multiple values

When a single interaction drives changes to multiple properties—color, size, position, opacity—you want them to move in sync. That's where updateTransition shines. Instead of juggling three separate animate*AsState calls and hoping they're timed right, a transition orchestrates them all.

Consider a shopping cart button that expands to show a badge when items are added:

kotlin
enum class CartState {
    EMPTY, HAS_ITEMS
}

@Composable
fun CartButton(itemCount: Int, onCartClick: () -> Unit) {
    val cartState = if (itemCount == 0) CartState.EMPTY else CartState.HAS_ITEMS
    val transition = updateTransition(cartState, label = "cartTransition")
    
    val backgroundColor by transition.animateColor(
        label = "bgColor",
        transitionSpec = { tween(300) }
    ) { state ->
        if (state == CartState.EMPTY) Color.Gray else Color.Blue
    }
    
    val badgeSize by transition.animateDp(
        label = "badgeSize",
        transitionSpec = { spring(stiffness = Spring.StiffnessMedium) }
    ) { state ->
        if (state == CartState.EMPTY) 0.dp else 24.dp
    }
    
    val badgeScale by transition.animateFloat(
        label = "badgeScale",
        transitionSpec = { spring(dampingRatio = Spring.DampingRatioHighBouncy) }
    ) { state ->
        if (state == CartState.EMPTY) 0f else 1f
    }
    
    FloatingActionButton(
        onClick = onCartClick,
        containerColor = backgroundColor,
        modifier = Modifier.size(56.dp)
    ) {
        Box(contentAlignment = Alignment.Center) {
            Icon(Icons.Default.ShoppingCart, "Cart")
            
            if (cartState == CartState.HAS_ITEMS) {
                Box(
                    modifier = Modifier
                        .size(badgeSize)
                        .scale(badgeScale)
                        .background(Color.Red, CircleShape),
                    contentAlignment = Alignment.Center
                ) {
                    Text(itemCount.toString(), color = Color.White, fontSize = 10.sp)
                }
            }
        }
    }
}

Notice how all three animations—background color, badge size, badge scale—are driven by the same cartState enum. When the state changes, they all animate in parallel. Each can have its own transitionSpec (duration, easing, spring parameters), but they're coordinated. This avoids the jarring feeling of unrelated motion and ensures the UI feels intentional.

Transitions are essential in complex interactions. In a chat app, when a user starts typing, the input field might grow taller, the send button color might shift, and a character counter might fade in. A transition ties all that motion together.

Keep it subtle — Short, purposeful animations

The biggest mistake teams make with animations is overindulgence. A 500ms slide feels snappy. A 1500ms slide feels sluggish and patronizing. A 50ms scale is imperceptible. A 150ms scale is satisfying. Animations should communicate, not entertain.

Guidelines from Material Design and industry practice suggest:

  • Micro-interactions (scale, fade, color change): 150–250ms.
  • Medium transitions (slide, expand): 200–400ms.
  • Full-screen navigation: 300–500ms. (Compose handles this in Modifier.animateContentSize() and navigation transitions.)
  • Spring animations: Typically settle in 300–600ms depending on dampingRatio and stiffness.

Test on real devices. An animation that feels smooth on a high-end emulator might jank on older hardware. Use Compose's @Composable Preview with slow animation playback (enabled in Android Studio) to catch problems early.

Also consider accessibility. A user with vestibular disorders may have OS-level animation preferences that reduce motion. Respect LocalDensity.current and check if the system prefers reduced motion:

kotlin
@Composable
fun AccessibleAnimation(targetValue: Float) {
    val prefersReducedMotion = isSystemInDarkTheme() // Simplified; in practice, use accessibility settings
    
    val animationDuration = if (prefersReducedMotion) 50 else 300
    val animatedValue by animateFloatAsState(
        targetValue = targetValue,
        animationSpec = tween(animationDuration),
        label = "accessibleAnim"
    )
    // Use animatedValue...
}

Subtle, purposeful motion is the sign of a polished app. Your animations should guide the eye, provide feedback, and stay out of the way.

Common mistakes

  • Forgetting the label: Always include label = "description" in animation functions. It helps Android Studio's animation preview and profilers track what's animating.
  • Animating inside a lambda without remembering state: If you create a mutableStateOf or call animateFloatAsState without remember, it will reinitialize on every recomposition, breaking the animation.
  • Blocking the main thread during animations: Keep animations lightweight. Avoid heavy computation inside animated values; compute results ahead of time.
  • Ignoring easing curves: A linear animation feels robotic. Use EaseInOutCubic, FastOutSlowIn, or springs for natural motion.
  • Overlapping too many animations: When five properties animate at once with different durations, the result feels chaotic. Coordinate them with transitions or stagger them deliberately.
  • Hard-coding animation specs in composables: Extract reusable animation specs into constants or parameters so designers and developers can tweak timing without touching code.

Key takeaways

  • Use animate*AsState for single-value animations; they're concise and integrate seamlessly with state.
  • AnimatedVisibility is your go-to for entering and exiting content; combine transitions like slideInVertically() + fadeIn() for richer effects.
  • updateTransition orchestrates multiple animations driven by the same state, ensuring they move together.
  • Keep durations short (150–400ms for most interactions) and respectful of accessibility preferences.
  • Test animations on real devices and use preview slow-motion mode to catch timing issues early.
  • Always label your animations and extract reusable specs so the team can maintain and iterate on motion consistently.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles