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.
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.
On this page
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.
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.
| API | Best For | Trigger | State Ownership |
|---|---|---|---|
code | Simple value transitions | State change | Automatic |
code | Composable swaps | State change | Automatic |
code | Fine-grained control | Manual | You manage it |
code | Coordinated multi-property | State change | Shared |
code | Looping animations | None (continuous) | Automatic |
The golden rule: start with the simplest API that solves your problem. If
animateColorAsStateAnimatableThese 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.
When a state value changes and you want a smooth visual transition,
animate*AsStateDpColorFloatIntOffsetRectIntOffsetIntSize@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
label[!TIP] Always pass a descriptive
to everycodelabelcall. The Compose Layout Inspector uses these labels to surface animation overdraw and unnecessary recompositions. Without labels, you are debugging blind.codeanimate*AsState
When the content itself changes, not just a property,
AnimatedContent@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]
renders both the old and new composables simultaneously during the transition. If your content is heavy, such as a largecodeAnimatedContentwith complex items, this doubles memory and compute pressure during the transition window. Profile it before shipping.codeLazyColumn
When you need to drive an animation from a gesture, sync multiple values, or interrupt an in-progress animation,
animate*AsStateAnimatableAnimatable@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
AnimatablesuspendanimateTo[!NOTE]
is thread-safe. You can callcodeAnimatablefrom any coroutine without worrying about race conditions. The internalcodeanimateToensures only one animation runs at a time, and new calls interrupt the previous one cleanly.codeMutex
When a single state change drives multiple animated properties,
updateTransitionAnimatable@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
updateTransitionrememberTransitionAnimations are worthless if they jank. After shipping 22+ apps, I have a checklist I run through for every animated screen.
| Cause | Impact | Fix |
|---|---|---|
| Animating layout properties (width, height) | Triggers re-measure on every frame | Animate transform instead (offset, scale) |
| Heavy composition in AnimatedContent | Double allocation during transition | Simplify content or use crossfade |
| Running animations on the main thread during I/O | Frame drops | Move I/O to code |
| Missing code code | Unnecessary recomposition | Always provide stable keys |
| Animating code | Overdraw | Animate visibility or use code |
The biggest mistake I see is animating layout properties. When you animate
widthheightoffsetscale// 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.
is your best friend for scale, rotation, alpha, and translation. It operates entirely on the GPU and never triggers re-measure or re-layout.codegraphicsLayer
graphicsLayer@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
shadowElevationgraphicsLayerelevationCard[!WARNING] When using
withcodegraphicsLayer, you must also setcodeshadowElevationandcodeshapeinside 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.codeclip = true
rememberInfiniteTransition@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
LazyColumn@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
isVisibleLazyColumnisVisibleLazyListStateChoose the simplest animation API first. Reach for
animate*AsStateAnimatedContentAnimatableNever animate layout properties when transform properties will do. Use
graphicsLayerwidthheightAlways label your animations. The
labelSudarshan 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.
Related Posts
Related Apps
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 meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read