Skip to main content

One post tagged with "Shared Element Transitions"

View All Tags

Polishing the UX: Implementing Declarative Shared Element Transitions with Jetpack Compose Navigation

Published: · 11 min read
Don Peter
Cofounder and CTO, Appxiom

Smooth, context-preserving transitions are a subtle but powerful way to make your app feel polished and intentional. Users instantly understand the continuity between a list item and its detail screen when that item “morphs” into place. In the View system, shared element transitions were notoriously finicky. In modern Compose, we can implement them declaratively with clean, testable code.

This post shows a production-ready way to add shared element transitions between destinations using:

  • Compose’s SharedTransition API (declarative, type-safe, designable)
  • Navigation with animations (to keep both sources and targets composed during transitions)
  • Real-world considerations like image loading, shapes, and scroll position

You’ll leave with an end-to-end feature you can drop into your app.

Prerequisites

  • Android Studio: Koala Feature Drop or newer (2024.1.2+)
  • Min SDK: 23+
  • Kotlin: 1.9.24+
  • Compose BOM: 2024.10.00+ (includes Compose 1.7.x; required for SharedTransition)
  • Navigation Compose: 2.7.0+ (graph)
  • Accompanist Navigation-Animation: version compatible with your Compose BOM (check release notes)
  • Material3, Lifecycle, Coil (optional but recommended)

Gradle (Kotlin DSL) example:

dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.10.00")
implementation(composeBom)

// Compose
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")

// Navigation
implementation("androidx.navigation:navigation-compose:2.7.7")

// Accompanist navigation animation (provides AnimatedVisibilityScope across destinations)
implementation("com.google.accompanist:accompanist-navigation-animation:<match-your-compose>")

// Lifecycle + coroutines
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")

// Images
implementation("io.coil-kt:coil-compose:2.6.0")
}

Note: Use an Accompanist version aligned with your Compose BOM. Check the Accompanist release notes to pick the matching artifact.

What we’re building

  • A photo grid → photo detail flow
  • The tapped image will morph from its position/size in the grid into a larger image on the detail screen
  • We’ll also morph container bounds (e.g., card to full-bleed) and handle back navigation

High-level architecture:

  • Single-activity app
  • NavHost inside a SharedTransitionLayout
  • Each shared element declares a stable key across both screens
  • Transitions are synchronized by AnimatedVisibilityScope provided by the navigation animation host

The mental model: SharedTransitionLayout + AnimatedVisibilityScope

Shared transitions in Compose rely on two things:

  1. A parent SharedTransitionLayout that owns the transition orchestration and caches shared content state by key.
  2. An AnimatedVisibilityScope that provides animation progress for entering/exiting destinations. Both the “from” and “to” Composables must be composed at the same time during the transition.

Accompanist Navigation-Animation gives each destination’s content an AnimatedVisibilityScope receiver, ensuring both screens are in the composition during transitions. That’s the key to making shared elements work with Navigation.

Project structure

  • ui/navigation/AppNavHost.kt
  • ui/feature/photos/PhotosGridRoute.kt
  • ui/feature/photos/PhotoDetailRoute.kt
  • data/PhotoRepository.kt (fake or real)
  • model/Photo.kt
  • util/ImageLoader.kt (optional)
@OptIn(androidx.compose.animation.ExperimentalSharedTransitionApi::class)
@Composable
fun AppNavHost() {
// Animated nav controller from accompanist
val navController = com.google.accompanist.navigation.animation.rememberAnimatedNavController()

// SharedTransitionLayout must wrap the nav host so both screens share the same scope
androidx.compose.animation.SharedTransitionLayout {
com.google.accompanist.navigation.animation.AnimatedNavHost(
navController = navController,
startDestination = "grid",
enterTransition = { fadeIn() },
exitTransition = { fadeOut() },
popEnterTransition = { fadeIn() },
popExitTransition = { fadeOut() }
) {
com.google.accompanist.navigation.animation.composable("grid") {
// `this` is AnimatedVisibilityScope
GridRoute(
onPhotoClick = { id -> navController.navigate("detail/$id") },
animatedVisibilityScope = this // pass to children
)
}
com.google.accompanist.navigation.animation.composable(
route = "detail/{id}",
arguments = listOf(
androidx.navigation.navArgument("id") { type = androidx.navigation.NavType.StringType }
)
) {
val id = it.arguments?.getString("id")!!
DetailRoute(
photoId = id,
onBack = { navController.popBackStack() },
animatedVisibilityScope = this
)
}
}
}
}

Why fade in/out? We’re delegating the bulk of motion to shared elements. A gentle fade avoids double motion or competing slide animations.

Grid screen with shared elements

Key points:

  • Keep item keys stable (use your domain id).
  • Use rememberSharedContentState(key) for each shared element.
  • Apply Modifier.sharedElement and optionally sharedBounds on a parent container to morph shapes/clipping.
  • Optionally make sure the tapped item is visible before navigating, so the start layout exists.
@OptIn(androidx.compose.animation.ExperimentalSharedTransitionApi::class)
@Composable
private fun SharedTransitionScope.GridRoute(
onPhotoClick: (String) -> Unit,
animatedVisibilityScope: AnimatedVisibilityScope,
viewModel: PhotosViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
) {
val state by viewModel.uiState.collectAsState()

val gridState = rememberLazyGridState()

// Top app bar, search, etc. omitted for brevity
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
state = gridState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(
items = state.photos,
key = { it.id } // critical: stable key equals the detail’s key
) { photo ->
val shared = rememberSharedContentState(key = photo.id)

// Optional: Card container morph (rounded → full in detail)
Card(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.sharedBounds(
state = shared,
animatedVisibilityScope = animatedVisibilityScope,
// Morph bounds using fast-out-slow-in tween
boundsTransform = androidx.compose.animation.SharedTransitionScope.BoundsTransform { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
},
clipInOverlayDuringTransition = true // prevents drawing outside while morphing
)
.clickable {
viewModel.onPhotoWillOpen(photo.id)
onPhotoClick(photo.id)
},
shape = RoundedCornerShape(16.dp),
) {
// The hero image that will morph
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(photo.url)
.diskCacheKey(photo.id) // keep memory/disk cache stable
.memoryCacheKey(photo.id)
.crossfade(true)
.build(),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.sharedElement(
state = shared,
animatedVisibilityScope = animatedVisibilityScope
),
contentScale = ContentScale.Crop
)
}
}
}
}

Notes:

  • sharedBounds on the Card morphs the container corner radius/bounds.
  • sharedElement on the AsyncImage morphs the image layer position/size.
  • Use the same key (photo.id) in both screens for both states.

Detail screen with shared elements

Mirror the same key, and apply sharedBounds/sharedElement. You can customize shapes and layout independently - the system will interpolate bounds.

@OptIn(androidx.compose.animation.ExperimentalSharedTransitionApi::class)
@Composable
private fun SharedTransitionScope.DetailRoute(
photoId: String,
onBack: () -> Unit,
animatedVisibilityScope: AnimatedVisibilityScope,
viewModel: PhotosViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
) {
val photo by remember(photoId) {
mutableStateOf(viewModel.getById(photoId))
}

val shared = rememberSharedContentState(key = photoId)

// Scaffold with collapsing toolbar etc. omitted for brevity
Box(modifier = Modifier.fillMaxSize()) {
// Full-bleed container morph (roundness → 0dp here)
Surface(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f) // keep aspect to reduce jump; can be dynamic
.align(Alignment.TopCenter)
.sharedBounds(
state = shared,
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = androidx.compose.animation.SharedTransitionScope.BoundsTransform { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
},
clipInOverlayDuringTransition = true
),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(0.dp)
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(photo.url)
.diskCacheKey(photo.id)
.memoryCacheKey(photo.id)
.crossfade(true)
.build(),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.sharedElement(
state = shared,
animatedVisibilityScope = animatedVisibilityScope
),
contentScale = ContentScale.Crop
)
}

// Top app bar overlay
SmallTopAppBar(
title = { Text(text = photo.title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
}
},
modifier = Modifier
.align(Alignment.TopStart)
.statusBarsPadding()
)
}
}

Customizing the motion

  • Duration and easing: Set per sharedBounds/sharedElement via boundsTransform.
  • Clip behavior: clipInOverlayDuringTransition guards against one element drawing over others. For tightly curved shapes, keep it true.
  • ContentScale: Using Crop on both sides reduces visual jumps. If targets have very different aspect ratios, consider a scrim or fade to mask stretch.

Example of a spring-based boundsTransform:

boundsTransform = androidx.compose.animation.SharedTransitionScope.BoundsTransform { _, _ ->
spring(dampingRatio = 0.85f, stiffness = 500f)
}

Ensuring the source item exists when navigating

Shared transitions require both ends to be composed simultaneously. If the source item is off-screen or gets disposed by the LazyGrid, the start state might be missing and the animation will pop.

Recommended pattern:

  • Before navigating, request the item to be visible.
  • Store last-clicked id in ViewModel (so it survives recomposition).

Example with BringIntoViewRequester:

@Composable
fun GridItem(
photo: Photo,
onClick: () -> Unit,
animatedVisibilityScope: AnimatedVisibilityScope
) {
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val coroutineScope = rememberCoroutineScope()

Card(
modifier = Modifier
.bringIntoViewRequester(bringIntoViewRequester)
.clickable {
coroutineScope.launch {
// Make sure it's in view before nav so it's measured
bringIntoViewRequester.bringIntoView()
onClick()
}
}
) {
/* content with shared elements */
}
}

If the grid is already visible and items are not too large, you may skip this, but it’s a solid guard for long lists.

Production tips and pitfalls

  • Stable keys are non-negotiable. The sharedContentState key must be identical on both sides (ideally a domain id).
  • Put SharedTransitionLayout as high as possible, typically above NavHost. If you scope it too low, the source/target may not share the same transition scope.
  • Avoid double animations. If you also slide the entire destination in/out, the shared element’s motion may look erratic. Prefer subtle fades or no-op transitions for the rest of the scene.
  • Image flicker: Use the same memory/disk cache keys with your image loader. Avoid resizing that produces different cached artifacts per screen.
  • Shape morphing: If you’re changing corner sizes or clipping, put sharedBounds on an outer container and sharedElement on a child image to decouple clipping from content movement.
  • Performance: Prefer clipInOverlayDuringTransition for tight masks; for very large images, ensure you downsample on the request to fit the device size to avoid heavy overdraw during transitions.
  • Testing: UI tests won’t “see” the animation, but they should verify that both screens render with the same stable keys and no crashes. Write a unit test around your ViewModel for id stability.

Common errors and how to fix them

  • IllegalStateException: Modifier.sharedElement used outside of a SharedTransitionScope

    • Cause: Not inside SharedTransitionLayout or lost the receiver.
    • Fix: Wrap your NavHost with SharedTransitionLayout and declare your screen composables as extension functions on SharedTransitionScope (or pass the scope down).
  • sharedElement requires AnimatedVisibilityScope

    • Cause: Using plain NavHost. It doesn’t keep both screens composed during transitions.
    • Fix: Use Accompanist Navigation-Animation or host your destinations in AnimatedContent/AnimatedVisibility manually. With Accompanist, the destination content lambda has AnimatedVisibilityScope as receiver.
  • The element “jumps” instead of animating

    • Cause: Keys differ or the source element is not currently composed (e.g., scrolled out).
    • Fix: Use the same stable key and ensure visibility (BringIntoView). Keep aspectRatio consistent between states to minimize reflow differences.
  • Overlapping or clipping artifacts

    • Cause: Complex z-ordering or shape clipping mismatch.
    • Fix: Use sharedBounds on an outer container with clipInOverlayDuringTransition = true and match shapes. Avoid elevation changes during transition; prefer zIndex where needed.
  • Image briefly empties then appears at destination

    • Cause: Separate loads with different cache keys or resizing.
    • Fix: Use identical cache keys and consistent ImageRequest. Consider placeholderMemoryCacheKey for the same id if using transformations.

Beyond the Code: Monitoring Transition Jank and Drop-Offs in Production

While declarative shared element transitions look flawless on high-end test hardware, production reality is messier. Because shared transitions require dual-destination composition, memory cache hits, and heavy layout passes simultaneously, low-to-mid tier devices can experience dropped frames (jank), UI freezes, or silent navigation drops.

If a customer taps a featured item in your product catalogue and experiences a 600ms frame freeze instead of a smooth morph, they perceive the app as sluggish - and frequently bounce before the detail screen finishes loading.

Traditional crash reporters won't help you here: they only log fatal exceptions, ignoring micro-stutters and navigation bottlenecks.

This is where Appxiom Real User Monitoring (RUM) comes in:

  1. Goal Friction Impact (GFI): Appxiom tracks actual user journeys (such as Product Browse ➔ Detail View ➔ Checkout) and correlates UI stutters, slow transitions, and memory spikes directly to conversion drop-offs.
  2. Zero-PII & High Performance: The native Appxiom SDK runs with a lightweight footprint consuming only 4% of the RAM required by traditional crash reporting tools - ensuring the monitor itself never induces frame drops during your animations.
  3. Release Quality Score: Benchmark every app update on a 0–10 scale to ensure your new UI enhancements don't introduce performance regressions across real-world device fleets.

Why this approach

  • Declarative and testable: No Fragment transactions or imperative hero mappings.
  • Predictable: Animation progress is driven by the navigation animation host, avoiding timing races.
  • Composable and composable: You can theme, refactor, and preview components independently while keeping transitions opt-in per element.

Next steps

  • Add predictive back support: Ensure your NavHost integrates system back progress; keep shared transitions subtle during swipe-to-back.
  • Animate more than one element: Text, chips, and FABs can also use sharedElement with the same pattern.
  • Material motion: Layer in fade-through or shared-axis transitions for non-shared parts of the scene.
  • Accessibility: Ensure motion is reduced when the user has “remove animations” enabled. Provide semantic continuity via contentDescription and headings.

Key takeaways

  • Put SharedTransitionLayout above your NavHost so both destinations share the same transition scope.
  • Use Accompanist Navigation-Animation to keep both screens composed and expose AnimatedVisibilityScope to content.
  • Drive shared transitions with rememberSharedContentState(key) and apply sharedElement/sharedBounds on both sides with the same stable key.
  • Keep motion focused: fade the rest of the scene, and let the hero element tell the story.
  • Harden for production with stable keys, cache consistency, and edge-case handling for lists and shapes.

Polished motion is a trust signal. With Compose’s SharedTransition API and animated navigation, you can ship it without hacking around the framework.

Turn Smooth Motion into Measurable Business Impact

Polished motion is a trust signal, but only when it runs smoothly for every user. Don't let invisible client-side jank and navigation drops erode your conversion funnels.

Explore Appxiom plans and pricing to start tracking Goal Friction Impact, or check out our developer documentation to integrate our lightweight SDK in under 5 minutes. Protect your app's performance with a 30-day free trial.