Skip to content

Shared elements

A shared element transition animates one piece of UI from its position on the outgoing screen to its position on the incoming one — a list row title growing into a detail header, for example.

Verified to compile, not verified visually

The scopes below are wired and the code compiles and runs on Android, but the animation itself has not been visually confirmed on a device. Treat this page as the intended API and check the result before relying on it.

Setup

Wrap the navigation host in SharedElementTransitionLayout, inside ContentWithOverlays if you use overlays:

ContentWithOverlays {
    SharedElementTransitionLayout(Modifier.fillMaxSize()) {
        NavigableOrbitContent(navigator, backStack, Modifier.fillMaxSize())
    }
}

That provides LocalSharedTransitionScope. The other half, LocalAnimatedVisibilityScope, is provided by AnimatedNavDecoration for each record.

Marking an element

Give the same key to the element on both screens:

Text(
    text = item,
    modifier = Modifier.orbitSharedElement("title-$item"),
)
Text(
    text = state.id,
    style = MaterialTheme.typography.headlineMedium,
    modifier = Modifier.orbitSharedElement("title-${state.id}"),
)

Keys must be unique within a screen and identical across the two screens. Deriving them from a stable id — not a list index — is what makes the pairing correct.

Degrading gracefully

orbitSharedElement returns the modifier unchanged when either scope is missing. A screen using it therefore still renders correctly:

  • inside OrbitContent with no navigation host,
  • under NoOpNavDecoration,
  • in a @Preview,
  • in a custom decoration that does not provide LocalAnimatedVisibilityScope.

You get no animation rather than a crash.

Custom decorations

If you write your own NavDecoration, provide the scope or shared elements silently stop working:

AnimatedContent(
    targetState = args.first(),
    contentKey = contentKey,
    transitionSpec = { fadeIn() togetherWith fadeOut() },
) { arg ->
    CompositionLocalProvider(LocalAnimatedVisibilityScope provides this) {
        key(contentKey(arg)) { content(arg) }
    }
}

Reaching the scopes directly

For effects orbitSharedElement does not cover, both composition locals are public:

val sharedScope = LocalSharedTransitionScope.current
val animatedScope = LocalAnimatedVisibilityScope.current

if (sharedScope != null && animatedScope != null) {
    with(sharedScope) {
        Box(
            Modifier.sharedBounds(
                rememberSharedContentState("card-$id"),
                animatedVisibilityScope = animatedScope,
            ),
        )
    }
}

sharedBounds suits containers that change shape; sharedElement, which orbitSharedElement uses, suits content that keeps its identity.

These APIs are marked @ExperimentalSharedTransitionApi by Compose, so expect them to shift between Compose releases.