| name | compose-motion |
| description | Jetpack Compose animation foundations - animate*AsState, AnimatedVisibility, Crossfade, updateTransition, SharedTransitionLayout, gestures. |
Compose Motion - Sub-skill
Jetpack Compose animation core. Loaded for Android Compose and Compose Multiplatform projects.
Concise rules here. Deep-dive in references/.
Baseline: Compose 1.7+ (April 2026 stable). Modern stable APIs only - no swipeable, no animateContentSize hacks where AnimatedContent is correct.
API Decision Tree
| Need | API |
|---|
| Single value over time | animateFloatAsState, animateDpAsState, animateColorAsState, etc. |
| Visibility / mount-unmount | AnimatedVisibility(visible) { ... } |
| Crossfade between states | Crossfade(target) { state -> ... } |
| Multi-state coordinated | updateTransition(target).animateFloat { ... } |
| Manual control / interruption | Animatable(initialValue) + animateTo(...) |
| Looping / infinite | rememberInfiniteTransition().animateFloat(...) |
| Shared elements | SharedTransitionLayout + Modifier.sharedElement(...) (Compose 1.7+) |
| Layout swap with anim | AnimatedContent(target) { ... } |
| Drag / swipe | Modifier.draggable + Animatable.snapTo/animateTo, or Modifier.anchoredDraggable for snap-points |
Rule: climb the ladder only when needed. animate*AsState covers 70% of cases. Reach for Animatable only when you need to interrupt, chain, or read velocity.
Spring API (opinionated defaults)
| Use | Spec |
|---|
| UI snap (modal, drawer, tab) | spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioNoBouncy) |
| Tactile (button press release, toggle) | spring(stiffness = Spring.StiffnessMedium, dampingRatio = 0.85f) |
| Bouncy reveal (toast, FAB, success) | spring(stiffness = Spring.StiffnessLow, dampingRatio = Spring.DampingRatioMediumBouncy) |
| Drag follow (1:1 finger tracking) | spring(stiffness = Spring.StiffnessHigh, dampingRatio = 1f) |
Stiffness constants: VeryLow 200, Low 400, MediumLow 700, Medium 1500, High 10000. Higher = faster settle. Damping constants: HighBouncy 0.2, MediumBouncy 0.5, LowBouncy 0.75, NoBouncy 1.0. Below 1.0 overshoots. Springs ignore durationMillis; if you need a deterministic duration, use tween(...) instead.
animate*AsState - The Bread and Butter
val targetAlpha = if (visible) 1f else 0f
val alpha by animateFloatAsState(
targetValue = targetAlpha,
animationSpec = spring(stiffness = Spring.StiffnessMedium),
label = "alpha",
)
Box(modifier = Modifier.alpha(alpha))
The label shows up in Layout Inspector / Animation Preview - always set it, future-you will thank present-you. Variants ship for Dp, Color, Offset, IntOffset, Size, Rect, Float, Int, and a generic animateValueAsState for custom types via TwoWayConverter.
AnimatedVisibility - Mount / Unmount with Anim
AnimatedVisibility(
visible = expanded,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(animationSpec = tween(150)),
) {
Panel()
}
Combine multiple enter/exit transitions with +. Respect motion-principles: exit shorter and simpler than enter (here 150ms tween fade vs full slide+fade enter). The content composable only runs while visible OR animating - safe to mount expensive children inside.
AnimatedContent - State-Driven Layout Swap
AnimatedContent(
targetState = currentTab,
transitionSpec = {
(slideInHorizontally { it } + fadeIn()) togetherWith
(slideOutHorizontally { -it } + fadeOut())
},
label = "tabs",
) { tab ->
TabContent(tab)
}
togetherWith runs enter and exit in parallel; using SizeTransform(clip = false) controls how the container resizes between contents. Keys matter: if targetState doesn't change identity, no transition fires.
Crossfade - Simple Fade Between States
Crossfade(targetState = isLoading, label = "loadState") { loading ->
if (loading) Spinner() else Content()
}
Use when you only need a fade. For anything richer (slide, scale, layout-aware), reach for AnimatedContent. Crossfade does NOT animate size - the container takes the size of the new content immediately.
updateTransition - Multi-Property Coordinated
val transition = updateTransition(targetState = expanded, label = "expand")
val width by transition.animateDp(label = "width") { if (it) 300.dp else 100.dp }
val color by transition.animateColor(label = "color") { if (it) Color.Blue else Color.Gray }
val corner by transition.animateDp(label = "corner") { if (it) 24.dp else 8.dp }
Box(
Modifier
.width(width)
.background(color, RoundedCornerShape(corner)),
)
Use when several properties animate together based on the same state. All children share the same transition timeline, so they finish in sync. Each animate* call accepts its own transitionSpec lambda for per-property tuning.
Animatable - Manual Control
val offsetX = remember { Animatable(0f) }
LaunchedEffect(triggerEvent) {
offsetX.animateTo(100f, spring())
offsetX.animateTo(0f, spring(dampingRatio = Spring.DampingRatioMediumBouncy))
}
Box(Modifier.offset { IntOffset(offsetX.value.roundToInt(), 0) })
Reach for Animatable when you need to interrupt (stop()), chain (animateTo returns when finished), read live velocity, or kick off decay (animateDecay). It is the imperative escape hatch for drag-then-fling, snap-back, and any flow animate*AsState cannot express.
SharedTransitionLayout (Compose 1.7+) - Hero Animations
SharedTransitionLayout {
AnimatedContent(targetState = currentScreen, label = "nav") { screen ->
when (screen) {
Screen.List -> ListScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedContent,
)
is Screen.Detail -> DetailScreen(
item = screen.item,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedContent,
)
}
}
}
with(sharedTransitionScope) {
Image(
painter = painter,
contentDescription = null,
modifier = Modifier.sharedElement(
state = rememberSharedContentState(key = "hero-${item.id}"),
animatedVisibilityScope = animatedVisibilityScope,
),
)
}
Two scopes plumbed down: SharedTransitionScope (where Modifier.sharedElement extension lives) and AnimatedVisibilityScope (the visibility context that drives the transition). rememberSharedContentState(key) keys MUST match across screens or no animation fires. Deep-dive in references/shared-transitions.md.
InfiniteTransition - Looping
val infinite = rememberInfiniteTransition(label = "loader")
val rotation by infinite.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "rotation",
)
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.rotate(rotation))
RepeatMode.Restart jumps back to start each cycle; RepeatMode.Reverse ping-pongs. Always provide a pause / off path for accessibility (see Reduced Motion section below). For one-shot decay (fling), use Animatable.animateDecay instead.
Gestures (quick map)
| Need | Modifier |
|---|
| Tap | Modifier.clickable(onClick = ...) |
| Tap + long-press | Modifier.combinedClickable(onClick = ..., onLongClick = ...) |
| Drag (single axis) | Modifier.draggable(state, orientation) |
| Multi-touch / custom | Modifier.pointerInput { detectDragGestures { ... } } |
| Scroll offset reading | LazyListState or ScrollState.value |
| Swipe-to-dismiss / snap points | Modifier.anchoredDraggable(state) (Compose 1.6+, replaces swipeable) |
| Pinch + pan + rotate | Modifier.transformable(state) |
Deep-dive in references/gestures-compose.md (NestedScrollConnection, conflict resolution, fling decay).
Anti-Patterns (BAD / GOOD)
1. Animating layout size instead of transform
val w by animateDpAsState(if (expanded) 300.dp else 100.dp)
Box(Modifier.width(w).height(60.dp))
val scale by animateFloatAsState(if (expanded) 3f else 1f, label = "scale")
Box(
Modifier
.width(100.dp)
.height(60.dp)
.graphicsLayer {
scaleX = scale
transformOrigin = TransformOrigin(0f, 0.5f)
},
)
If the actual layout size MUST change (parent depends on it), use Modifier.animateContentSize() so neighbors animate too, or wrap the swap in AnimatedContent.
2. LaunchedEffect(true) / LaunchedEffect(Unit) with hidden inputs
LaunchedEffect(true) {
offsetX.animateTo(target)
}
LaunchedEffect(triggerKey) {
offsetX.animateTo(target)
}
If you genuinely want "run once", use LaunchedEffect(Unit) on purpose AND ensure you don't capture varying state - or hoist the captured values out. When in doubt, key on the values you read.
3. Forgetting key in a LazyColumn with item animations
LazyColumn {
items(list) { item ->
AnimatedVisibility(visible = item.expanded) { ItemRow(item) }
}
}
LazyColumn {
items(list, key = { it.id }) { item ->
Row(modifier = Modifier.animateItem()) {
AnimatedVisibility(visible = item.expanded) { ItemRow(item) }
}
}
}
Modifier.animateItem() (Compose 1.7+, replaces animateItemPlacement) handles insert/remove/move automatically when key is stable.
4. Nesting AnimatedContent inside scrolling list items
LazyColumn {
items(list, key = { it.id }) { item ->
AnimatedContent(targetState = item.state) { state -> Row(state) }
}
}
LazyColumn {
items(list, key = { it.id }) { item ->
val color by animateColorAsState(if (item.selected) selBg else bg, label = "rowBg")
Row(Modifier.background(color)) { Content(item) }
}
}
Rule: heavy animation containers (AnimatedContent, SharedTransitionLayout) belong at screen scope, not per row.
Reduced Motion - Respect It
See ../motion-principles/SKILL.md for the cross-platform doctrine.
@Composable
fun rememberReduceMotion(): Boolean {
val context = LocalContext.current
return remember {
Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
) == 0f
}
}
val reduce = rememberReduceMotion()
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = if (reduce) snap() else spring(),
label = "alpha",
)
ANIMATOR_DURATION_SCALE covers the developer-options "Animations off" toggle. For the user-facing accessibility setting on Android 14+ ("Remove animations" in Settings -> Accessibility), use AccessibilityManager.areTransitionsEnabled() (API 34+). Deep-dive in ../mobile-principles/references/accessibility-mobile.md.
Quick Reference
| Need | Load |
|---|
| Shared transitions deep-dive | references/shared-transitions.md |
| Gestures + nestedScroll patterns | references/gestures-compose.md |
| Recomposition / jank / Layout Inspector | references/recomposition-and-anim.md |
| Advanced (M3 Expressive, AGSL, Canvas) | ../compose-graphics/SKILL.md |
| CMP / KMP patterns | ../compose-multiplatform/SKILL.md |
| Mobile UX context | ../mobile-principles/SKILL.md |
| Foundation (timing, easing, a11y) | ../motion-principles/SKILL.md |
Sources