A shared element transition is one element pretending to be two

Shared element transitions match two composables by key and animate between their bounds. Understanding that neither element travels — a third overlay draws the motion — explains the scope requirements and the common failures.

6 min read
androidcomposekotlinanimation

Day 65 — One element pretending to be two

Day 65 of 100. The transition that makes a list-to-detail navigation feel continuous, and the one whose mental model is most often wrong.

The symptom

A thumbnail that should grow into a hero image, hand-rolled:

// In the list
Image(photo.url, Modifier.size(80.dp).clickable { navigate(photo.id) })

// In the detail screen
Image(photo.url, Modifier.fillMaxWidth().aspectRatio(1f))

Tapping cuts abruptly to the detail screen. The obvious instinct is to animate the thumbnail's size and position toward the hero's — but the thumbnail belongs to a screen that is being removed, and the hero belongs to one that doesn't exist yet.

There is no moment when both are on screen and animatable by the same code.

Why the obvious fix fails

The obvious fix is to keep both screens composed and animate across them:

Box {
    ListScreen(Modifier.alpha(1f - progress))
    DetailScreen(Modifier.alpha(progress))
}

Now both are composed, and the two images are still different composables in different layout subtrees. To animate between them you'd need each one's position in window coordinates, a third element drawn above both, and a way to hide the originals while it flies.

That is exactly what the API does, and it's a lot to hand-roll correctly.

Neither element moves — a matched key lets an overlay draw the handoff between two bounds

The actual mechanism

Two composables declare a shared key. During the transition, Compose measures both, hides them, and draws a single element in an overlay that animates between the two sets of bounds.

SharedTransitionLayout {
    AnimatedContent(targetState = selectedId, label = "screen") { id ->
        if (id == null) {
            PhotoList(
                onSelect = { selectedId = it },
                sharedScope = this@SharedTransitionLayout,
                animatedScope = this@AnimatedContent,
            )
        } else {
            PhotoDetail(
                id = id,
                sharedScope = this@SharedTransitionLayout,
                animatedScope = this@AnimatedContent,
            )
        }
    }
}

and on each image:

with(sharedScope) {
    Image(
        painter = …,
        modifier = Modifier
            .sharedElement(
                sharedContentState = rememberSharedContentState(key = "photo-$id"),
                animatedVisibilityScope = animatedScope,
            )
            .size(80.dp),
    )
}

Three requirements, and each one follows from the mechanism:

A SharedTransitionLayout ancestor. The overlay has to be drawn somewhere above both screens, and this is that place. It also provides the coordinate space the two sets of bounds are expressed in.

An AnimatedVisibilityScope. The transition needs to know when each element is entering or leaving — Day 64's lifecycle problem, reused. That's why the scope is passed in rather than inferred.

Matching keys. "photo-$id" on both sides. A key that doesn't match produces no error and no animation, which is the most common failure and the hardest to spot.

sharedElement versus sharedBounds

Two modifiers, and picking wrong is the second most common problem.

sharedElement — the two composables render the same content. The element is drawn once and its bounds animate. Right for an image, an avatar, a logo.

sharedBounds — the two render different content that occupies a corresponding region. The container's bounds animate while the contents cross-fade. Right for a card that becomes a screen, or a title that changes from one line to three.

Modifier.sharedBounds(
    sharedContentState = rememberSharedContentState(key = "card-$id"),
    animatedVisibilityScope = animatedScope,
    enter = fadeIn(),
    exit = fadeOut(),
    resizeMode = ScaleToBounds(),
)

Using sharedElement where the content differs produces a visible snap as the content swaps; using sharedBounds for identical content adds an unnecessary cross-fade.

Why it goes wrong

Four failures, all traceable to the mechanism:

Keys don't match. Usually a string built differently on each side, or a key derived from an index rather than an id — Day 23's identity problem again.

The element is clipped by an ancestor. The overlay draws in the shared layout's coordinate space, but a clip on an ancestor of the original can cut the element during the handoff. Moving the clip, or using clipInOverlayDuringTransition, resolves it.

Sizes are wildly different. Animating an 80dp thumbnail to a full-bleed hero involves a large scale change, and a contentScale mismatch between the two makes the image visibly squash mid-flight. Both sides should use the same contentScale — usually ContentScale.Crop from Day 55.

The element isn't composed on one side yet. An image still loading in the detail screen has no bounds to animate to. Reserving the space with aspectRatio — Day 55 again — gives the transition something to target.

Navigation integration

With Navigation Compose, the scopes come from the graph:

SharedTransitionLayout {
    NavHost(navController, startDestination = "list") {
        composable("list") {
            PhotoList(
                sharedScope = this@SharedTransitionLayout,
                animatedScope = this,      // the composable scope IS an AnimatedVisibilityScope
            )
        }
        composable("detail/{id}") { … }
    }
}

Each composable destination scope implements AnimatedVisibilityScope, which is what makes this work across real navigation rather than only across an AnimatedContent.

When not to use it

Shared elements are expensive — extra measurement, an overlay, and a scale animation on what may be a large image. They earn that when there is genuine continuity of identity: this photo becomes that photo, this card becomes that screen.

They don't earn it for decoration. A shared element between two unrelated screens is motion for its own sake, and it costs frames on exactly the devices Day 60 warned about.

The reduced-motion setting from Day 61 applies here more than anywhere else in the pillar. A large element flying across the screen is precisely the motion that causes discomfort, and the fallback — a plain cross-fade — is one conditional on the AnimatedContent spec rather than a separate code path.

How to prove it

Slow motion is the tool, because the interesting part is the middle:

Android Studio's Animation Preview, or Developer Options → Animator duration scale → 5×, makes the handoff observable. What to look for: the element should be continuous — no flash of the original, no snap at either end, no squash mid-flight.

A flash at the start usually means the key matched late; a snap at the end usually means the destination bounds weren't ready.

For the key-matching failure specifically, a log in rememberSharedContentState on both sides is the fastest diagnosis. Two keys that differ by a formatting detail look identical in review and produce no animation at all. Deriving the key from a shared constant — fun photoKey(id: String) = "photo-$id" — removes the class of bug entirely, and is worth doing the first time rather than after debugging it once.

What this generalizes to

The idea is identity across contexts, not movement between them. Nothing travels; two things declare they are the same thing, and the system draws the interpolation. That's why the key matters more than the geometry.

It's the same primitive as Day 23's list keys and Day 8's slot identity, applied across screens rather than within a list. Once "these two are the same thing" is expressible, the animation is a consequence — and where it fails, the failure is almost always in the identity rather than the motion.

The web's View Transitions API arrived at an identical design — a view-transition-name on both sides, an overlay drawn by the browser, and the same failure when the names don't match. Two ecosystems, independently, concluded that the hard part is naming rather than tweening.

Tomorrow, Day 66 closes the pillar with testing animations, and the clock control that makes them deterministic.


Day 65 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Shared element transitions.