1. Phase deferral for animations (skip Composition to Draw and Layout)
When animating visual properties (such as alpha, scale, rotation, color, or translation), never read the animated state directly in the Composable body during the Composition phase. Reading state during composition causes the entire composable tree to recompose on every animation tick (60 to 120 times per second).
Wrap animated state reads in lambdas so evaluation is deferred to the Draw or Layout phase.
A. Alpha, rotation, and scale: defer to Draw with graphicsLayer
Bad (Recomposes on every animation frame):
val alpha by animateFloatAsState(targetValue = targetAlpha, label = "Alpha") // Reading alpha here registers a composition dependency on every frame Box(modifier = Modifier.alpha(alpha))
Optimized (Bypasses Composition and Layout, evaluates in Draw phase):
val alpha by animateFloatAsState(targetValue = targetAlpha, label = "Alpha") // graphicsLayer with lambda skips composition and runs in the draw phase Box( modifier = Modifier.graphicsLayer { this.alpha = alpha } )
B. Animated offsets: defer to Layout with Modifier.offset
Bad (Recomposes on every pixel shift):
val animatedOffset by animateDpAsState( targetValue = targetOffset, label = "Offset", ) Box(modifier = Modifier.offset(x = animatedOffset, y = 0.dp))
Optimized (Skips Composition, goes straight to Layout phase):
val animatedOffset by animateIntAsState( targetValue = targetOffsetPx, label = "Offset", ) Box( modifier = Modifier.offset { IntOffset(x = animatedOffset, y = 0) } )
C. Animated size: defer to Layout with Modifier.layout or animateBounds
Animating container dimensions using animateDpAsState and Modifier.size(...)
directly in the Composable body triggers recomposition of the composable and its
children on every animation tick.
Use layout-phase constraint animation or Lookahead bounds animation instead:
Bad (Recomposes entire subtree on every animation frame):
val animatedSize by animateDpAsState( targetValue = targetSize, label = "Size", ) Box(modifier = Modifier.size(animatedSize))
Optimized (Phase Deferral with
Modifier.layout): Animate constraints in the layout pass without re-triggering composition:val animatedWidth by animateIntAsState( targetValue = targetWidthPx, label = "Width", ) Box( modifier = Modifier.layout { measurable, constraints -> val placeable = measurable.measure( constraints.copy( minWidth = animatedWidth, maxWidth = animatedWidth ) ) layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) } } )
Optimized (Lookahead Scope with
animateBounds): Inside aLookaheadScope, animate size changes smoothly usinganimateBounds:with(lookaheadScope) { Box(modifier = Modifier.animateBounds(this, Modifier.size(finalSize))) }
D. Custom composable parameters (lambda providers)
When creating custom composables that animate properties internally, pass a
lambda provider (alphaProvider: () -> Float or offsetProvider: () ->
IntOffset) instead of the raw animated value. This allows the caller to avoid
reading the state in Composition and allows the child to defer the read to
graphicsLayer or offset.
Bad (Caller must read state in Composition to pass Float):
@Composable fun AnimatedCard(alpha: Float) { Box(modifier = Modifier.graphicsLayer { this.alpha = alpha }) }
Optimized (Caller passes lambda; read is deferred until child's Draw phase):
@Composable fun AnimatedCard(alphaProvider: () -> Float) { Box(modifier = Modifier.graphicsLayer { this.alpha = alphaProvider() }) }
2. Shared transitions and shared elements (SharedTransitionScope)
SharedTransitionScope enables coordinated bounds and element transitions
across layouts and navigation destinations.
1. Defer sharedBounds and sharedElement in fast-scrolling lists
Recommendation: For long or fast-scrolling lists (LazyColumn, LazyRow,
LazyVerticalGrid, HorizontalPager), consider attaching
Modifier.sharedBounds or Modifier.sharedElement only when items enter the
visible viewport using Modifier.onVisibilityChanged.
While spatial coordinate tracking is only enabled when a match is found, unconditionally attaching shared transition modifiers introduces modifier instantiation and state observation overhead for pre-fetched items that may never end up on screen during fast scroll flings. Deferring attachment until visible avoids this instantiation cost for offscreen items.
Note: This recommendation applies to current versions of Compose. Upstream platform performance optimizations to make
Modifier.sharedBoundsandModifier.sharedElementlighter-weight and avoid prefetch overhead are in active development.
Before (Instantiation runs on all pre-fetched items even if never displayed):
@Composable fun FeedItemRow( item: FeedItem, sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope, ) { with(sharedTransitionScope) { ItemContent( modifier = Modifier .fillMaxWidth() .sharedBounds( rememberSharedContentState(key = item.id), animatedVisibilityScope = animatedVisibilityScope, ) ) } }
Optimized (Shared transition modifier deferred until item enters viewport):
@Composable fun FeedItemRow( item: FeedItem, sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope, ) { var isVisibleInWindow by remember { mutableStateOf(false) } with(sharedTransitionScope) { val sharedBoundsModifier = if (isVisibleInWindow) { Modifier.sharedBounds( rememberSharedContentState(key = item.id), animatedVisibilityScope = animatedVisibilityScope, ) } else { Modifier } ItemContent( modifier = Modifier .fillMaxWidth() .onVisibilityChanged( minFractionVisible = 0.001f, ) { visible -> isVisibleInWindow = visible } .then(sharedBoundsModifier) ) } }
3. Persistent Animatable in custom modifiers (Modifier.Node)
When building custom animated modifiers, do not use composed { ... } with
remember { Animatable(...) } and LaunchedEffect.
Store Animatable directly as a member property on the Modifier.Node and
launch the animation in onAttach() using the node's built-in coroutineScope:
private class FadeRevealNode( var targetAlpha: Float, var durationMillis: Int ) : Modifier.Node(), DrawModifierNode { // Persistent node property (avoids remember overhead) private val alpha = Animatable(0f) override fun onAttach() { // Launches animation tied directly to node lifecycle coroutineScope.launch { alpha.animateTo(targetAlpha, tween(durationMillis)) } } override fun ContentDrawScope.draw() { drawContext.canvas.saveLayer( bounds = size.toRect(), paint = Paint().apply { this.alpha = this@FadeRevealNode.alpha.value } ) drawContent() drawContext.canvas.restore() } }