Shared Bounds Expansion

Tap a card and watch it expand into a detail view through SharedTransitionLayout. Matching keys tie source and destination bounds together.

Compose APIs
SharedTransitionLayoutAnimatedContentsharedBounds
Try tweaking
BOUNDS_DURATION_MSBOUNDS_EASINGDETAIL_HEIGHT_DPcard list

Material container transforms used to require careful coordinate math. You measured the source view, captured its position on screen, snapshotted the destination layout, and animated a temporary surface between the two. The choreography was correct but the wiring was tedious. SharedTransitionLayout flips that model on its head and makes container morphs declarative: you mark two composables with the same logical key and Compose interpolates the rectangles for you.

In this article, you'll explore how AnimationExample12 builds a tap to expand card interaction with SharedTransitionLayout, how AnimatedContent swaps between a list view and a detail view, how rememberSharedContentState ties matching elements across composition trees, and how BoundsTransform controls the geometric morph between source and destination bounds.

How the example is structured

The example renders a single screen that holds two distinct UI states. When selectedId is null, you see a vertical stack of three colored cards, each 80 dp tall. When the user taps a card, selectedId becomes that card's id and the screen replaces the list with a single expanded card whose height grows to DETAIL_HEIGHT_DP. Tapping the expanded card sets selectedId back to null and collapses it into the list.

The state itself is a single mutableStateOf<String?>(null):

var selectedId by remember { mutableStateOf<String?>(null) }

Around that state lives a SharedTransitionLayout containing an AnimatedContent. The AnimatedContent reads selectedId as its targetState and renders one of two child composables based on whether the value is null. That gives you the surface level swap. The interesting part is what happens between those two states: the tapped card visually morphs into the detail view rather than fading in from a separate location.

SharedTransitionLayout and the shared scope

SharedTransitionLayout wraps every composable that needs to participate in a shared bounds animation. Inside it, you receive a SharedTransitionScope that exposes Modifier.sharedElement and Modifier.sharedBounds, the two extensions that mark a composable as shareable.

Looking at the wiring in the example:

SharedTransitionLayout(modifier = Modifier.fillMaxWidth()) {
  AnimatedContent(
    targetState = selectedId,
    transitionSpec = {
      (fadeIn(tween(BOUNDS_DURATION_MS, easing = BOUNDS_EASING)) togetherWith
        fadeOut(tween(BOUNDS_DURATION_MS, easing = BOUNDS_EASING)))
    },
    label = "shared-bounds",
  ) { current ->
    // CardList or CardDetail
  }
}

Notice the structure: SharedTransitionLayout is the outer container, and AnimatedContent lives inside it. This ordering matters. The shared scope must be in scope for both the outgoing and the incoming children of AnimatedContent, otherwise the transition system has no way to correlate elements across the swap. The example then forwards this@SharedTransitionLayout as scope and this@AnimatedContent as visibilityScope into the child composables, so they can attach Modifier.sharedElement to their own composables.

The transitionSpec here is a plain fade. The togetherWith operator simply says "play this fade in alongside that fade out". That fade governs how non shared content appears and disappears. The bounds morph itself is not driven by this spec, it is driven by the boundsTransform you'll see next.

Matching keys via rememberSharedContentState

The bridge between two composables is a string key. Both CardList and CardDetail register the same key for the same logical card:

.sharedElement(
  sharedContentState = rememberSharedContentState(key = "card-${model.id}"),
  animatedVisibilityScope = visibilityScope,
  boundsTransform = { _, _ ->
    tween(durationMillis = boundsDurationMs, easing = boundsEasing)
  },
)

The same rememberSharedContentState(key = "card-${model.id}") line appears inside CardDetail. When AnimatedContent swaps the list for the detail, the runtime walks the outgoing tree and the incoming tree, finds two sharedContentState entries that share the same key, and decides "these are the same logical element". From that moment, instead of fading the source out and the destination in, it captures the source rectangle, captures the destination rectangle, and animates a single rendering between the two.

This is why each card carries an id rather than its position. The key is content based, not index based. You could reorder the list, remove cards, or insert new ones, and the bounds animation would still find its partner so long as the id is stable. If you accidentally generate a new key on every recomposition (for example by interpolating a random value), the runtime would treat each frame as a fresh element and you'd lose the morph entirely.

BoundsTransform: the spec that drives the morph

BoundsTransform is the parameter that controls how the rectangle interpolates between source and destination. The example uses a tween with two tweakable knobs:

boundsTransform = { _, _ ->
  tween(durationMillis = boundsDurationMs, easing = boundsEasing)
}

The lambda receives the initial Rect and the target Rect, both of which are ignored here. You return an AnimationSpec<Rect> that describes the timing curve. The runtime then samples that spec on every frame, computes an interpolated rectangle between source and target, and lays out the shared composable into that rectangle.

Two things are worth calling out about this signature. First, the spec is per transition, not per composable. Every time selectedId flips, the lambda is invoked again, so you can return a different spec depending on the direction or the rectangles involved. Second, the spec animates the entire Rect, meaning x, y, width, and height interpolate together with the same timing function. That is why the card appears to glide and grow as one continuous shape rather than first sliding and then resizing.

The fade transitionSpec on AnimatedContent and the boundsTransform on sharedElement both reuse BOUNDS_DURATION_MS and BOUNDS_EASING. Sharing the values keeps the fade and the morph synchronized. If the fade finished before the morph, you'd see the destination card pop into its final shape with no surrounding context. If the morph finished first, you'd briefly see a sharp card sitting underneath fading text. Matching the durations avoids both problems.

Tweaking duration, easing, layout

AnimationExample12 exposes four tweakables. Each one changes a specific dimension of the interaction.

  • BOUNDS_DURATION_MS: defaults to 500. Lower it to 200 and the card snaps open with little ceremony, useful when the detail is a quick preview. Raise it to 900 and the morph becomes a deliberate, almost cinematic motion. Both the fade and the bounds animation respond to this value, so they stay in sync.
  • BOUNDS_EASING: defaults to FastOutSlowInEasing. This curve accelerates quickly and decelerates into the destination, which feels natural for a tap response because the user's finger has just released. Swapping in LinearEasing removes the acceleration entirely, which makes the motion feel mechanical. Swapping in EaseOutBack adds a small overshoot at the end, which works well for playful UIs but can feel unstable in a productivity surface.
  • DETAIL_HEIGHT_DP: defaults to 260. The list cards are fixed at 80 dp. The morph interpolates between those two heights, so a larger value produces a longer vertical sweep. Setting it equal to 80 dp would still trigger the transition, but visually nothing would move because source and destination would share the same rectangle.
  • The CARDS list: each entry contributes a sharedElement keyed by card-${id}. Adding a new card with a new id immediately gives you a new shared element with no extra plumbing. Reusing an existing id across two cards would be a bug because two source rectangles would compete for the same destination, and the runtime cannot know which one to morph from.

Conclusion

In this article, you've explored how AnimationExample12 composes a tap to expand interaction from three pieces: SharedTransitionLayout providing the shared scope, AnimatedContent swapping between list and detail states, and Modifier.sharedElement paired with a stable key tying the source card to the destination card. The fade transitionSpec handles non shared content, while BoundsTransform drives the rectangular morph using BOUNDS_DURATION_MS and BOUNDS_EASING.

Understanding this layering helps you reason about when shared bounds will and will not work. The shared scope must wrap both children of AnimatedContent. The key must be stable across recompositions and unique within a single transition. The bounds spec and the content fade should usually share timing so the morph and the cross fade land on the same frame. When any of these conditions break, you'll see the symptoms immediately: a card that snaps instead of glides, a bounds that animates from the wrong position, or a fade that finishes too early.

Whether you're building a list to detail expansion like the one in this example, a hero image transition between a feed and a viewer, or a playful container morph for an onboarding flow, the same three primitives apply. SharedTransitionLayout defines the boundary, rememberSharedContentState correlates the elements, and BoundsTransform choreographs the geometry. With those in place, the rest is just deciding which composables deserve to travel together.

As always, happy coding!

Jaewoong (skydoves)