← All shorts
28Compose 1.7+2 min read

Shared Element Transitions in Compose

Animate a thumbnail morphing into its detail view with SharedTransitionLayout instead of a hard navigation cut.

Ordinary navigation transitions cross-fade or slide the whole screen. The thumbnail in the list disappears, then reappears full-size in the detail view — two unrelated images as far as the eye is concerned, with no visual continuity between them.

#The old way

@Composable
fun ImageDetail(imageUrl: String) {
    var isExpanded by remember { mutableStateOf(false) }

    if (!isExpanded) {
        AsyncImage(
            model = imageUrl,
            contentDescription = null,
            modifier = Modifier
                .size(80.dp)
                .clickable { isExpanded = true }
        )
    } else {
        AsyncImage(
            model = imageUrl,
            contentDescription = null,
            modifier = Modifier
                .fillMaxWidth()
                .aspectRatio(1f)
                .clickable { isExpanded = false }
        )
    }
}

80.dp one frame, full width the next. No morph, no continuity — a jump.

#The new way

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun ImageDetail(imageUrl: String) {
    var isExpanded by remember { mutableStateOf(false) }

    SharedTransitionLayout {
        AnimatedContent(targetState = isExpanded, label = "image-expand") { expanded ->
            AsyncImage(
                model = imageUrl,
                contentDescription = null,
                modifier = if (!expanded) {
                    Modifier
                        .size(80.dp)
                        .sharedElement(
                            rememberSharedContentState(key = "hero-image"),
                            animatedVisibilityScope = this@AnimatedContent
                        )
                        .clickable { isExpanded = true }
                } else {
                    Modifier
                        .fillMaxWidth()
                        .aspectRatio(1f)
                        .sharedElement(
                            rememberSharedContentState(key = "hero-image"),
                            animatedVisibilityScope = this@AnimatedContent
                        )
                        .clickable { isExpanded = false }
                }
            )
        }
    }
}

#Why it matters

  • The element morphs position and size across the transition instead of cutting. It reads as one continuous object rather than two unrelated images.
  • rememberSharedContentState(key) is what links the two states. Matching keys on both sides, and Compose interpolates the bounds for you.
  • Works with AnimatedContent, as shown here, or with NavHost — so it isn't limited to top-level navigation between screens.

#Gotcha

sharedElement only animates when source and target are both inside the same SharedTransitionScope and part of the same AnimatedContent/NavHost transition at the moment it runs. Move the expanded state into a separate Dialog, or into an unrelated composition, and the modifier silently does nothing. No crash. No animation. A hard cut, and no error to point at.

androidcomposeanimation

Related shorts