In Jetpack Compose, Hero Animation (also known as Shared Element Transition) is not officially built-in like in Flutter, but you can create it manually using AnimatedContent, BoxWithConstraints, and MotionLayout or third-party libraries like Accompanist Navigation-Material.
So, what do we do?
We build it manually! ๐ช
In this article, you'll learn how to implement a Hero animation manually in Jetpack Compose, and make your UI feel delightful and polished. We'll walk through everything — from project setup to final animation — with clear code and plain-English explanations.
๐ What is Hero Animation?
Imagine you're in an e-commerce app. You tap a product from a grid and it smoothly expands into a full product detail screen. A Hero Animation, also known as a Shared Element Transition, is a technique used in UI design where an element transitions smoothly between two different screens, creating a visually connected experience for the user. The element (say, product image) is shared between the two screens and animated smoothly.
๐ ️ Tools & Dependencies
Jetpack Compose doesn't provide shared element transitions yet, so we'll use basic animation APIs like:
- animateDpAsState
- animateFloatAsState
- BoxWithConstraints
- Navigation-Compose
Dependencies
Add this to your build.gradle file:
implementation ("androidx.navigation:navigation-compose:2.9.2")
Now, let’s build the animation!
✨ How to Create Hero Animation in Jetpack Compose
๐จ Our Plan
We’ll build:
A List Screen showing a small red box (our “hero”).
When tapped, it navigates to a Detail Screen where the same red box expands and reveals more info — mimicking a Hero animation.
๐งฑ Step 1: Setup Navigation in Jetpack Compose
We’ll use NavHost to handle screen transitions.
@Composable
fun HeroApp() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "list") {
composable("list") { HeroListScreen(navController) }
composable("detail") { HeroDetailScreen() }
}
}
✅ Now we can switch between screens like a pro.
๐ผ️ Step 2: Hero List Screen (with the tappable item)
Here's where the animation begins — a small red box that the user taps to move to the next screen.
@Composable
fun HeroListScreen(navController: NavController) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable { navController.navigate("detail") },
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(100.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color.Red),
contentAlignment = Alignment.Center
) {
Text("Tap Me", color = Color.White)
}
}
}
๐ง Explanation:
We center a red box inside the screen. When tapped, it navigates to the "detail" screen using the navigation controller.
๐งฌ Step 3: Hero Detail Screen (Animated Version)
Now we make it feel like the red box has smoothly “grown” into its larger version.
@Composable
fun HeroDetailScreen() {
var animateStart by remember { mutableStateOf(false) }
val size by animateDpAsState(
targetValue = if (animateStart) 250.dp else 100.dp,
animationSpec = tween(600)
)
val corner by animateDpAsState(
targetValue = if (animateStart) 32.dp else 20.dp,
animationSpec = tween(600)
)
// Trigger animation after screen is drawn
LaunchedEffect(Unit) {
animateStart = true
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(size)
.clip(RoundedCornerShape(corner))
.background(Color.Red),
contentAlignment = Alignment.Center
) {
Text("Hero Detail", color = Color.White)
}
}
}
๐ง Explanation:
- We use animateDpAsState to animate the box’s size and corner radius.
- When the screen appears (LaunchedEffect(Unit)), the animation is triggered.
- The red box expands and looks like it continued from the previous screen.
๐ This gives a smooth hero transition effect without needing shared elements!
๐ Final Touch: Preview It
Make sure you call HeroApp() in your MainActivity:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
CodingComposeTheme {
HeroApp()
}
}
}
}
OUTPUT:
๐ก Pro Tips
- You can enhance this further by animating position, scale, opacity, or even using Accompanist Navigation-Animation for smoother screen transitions.
- Wrap animations in a reusable composable for multiple shared elements.
- ๐ Try expanding this example — use it with images, profile cards, or product UIs. Your users will love the attention to detail.
๐งช Experimental: Shared Element Transition (Upcoming in Compose)
Jetpack Compose team is working on native Shared Element APIs, but currently you have to use workarounds.
Hero Animation from a Grid of Product Cards
✨ What We’ll Build:
- A grid layout with multiple product cards (image + name).
- Tap any product → Hero-like animation to detail screen.
- Manual animation using animateDpAsState, Navigation, and shared product state.
๐งฑ Step 1: Product Data Model
data class Products(
val id: Int,
val name: String,
val imageRes: Int
)
๐งฑ Step 2: Sample Product List
val sampleProducts = listOf(
Products(1, "Red Sneakers", R.drawable.rice1),
Products(2, "Blue T-Shirt", R.drawable.shirt1),
Products(3, "Black Watch", R.drawable.watch1),
Products(4, "Smartphone", R.drawable.phone11),
)
๐งฑ Step 3: Navigation Setup
@Composable
fun HeroGridApp() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "list") {
composable("list") {
ProductGridScreen(navController)
}
composable(
"detail/{productId}",
arguments = listOf(navArgument("productId") { type = NavType.IntType })
) { backStackEntry ->
val productId = backStackEntry.arguments?.getInt("productId")
val product = sampleProducts.find { it.id == productId }
product?.let {
ProductGridDetailScreen(product = it)
}
}
}
}
๐จ Step 4: Grid Screen with Clickable Hero Cards
@Composable
fun ProductGridScreen(navController: NavController) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize()
) {
items(sampleProducts) { product ->
Column(
modifier = Modifier
.padding(8.dp)
.clickable {
navController.navigate("detail/${product.id}")
},
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(product.imageRes),
contentDescription = null,
modifier = Modifier
.size(120.dp)
.clip(RoundedCornerShape(16.dp))
)
Spacer(Modifier.height(8.dp))
Text(product.name, fontSize = 14.sp, maxLines = 1)
}
}
}
}
๐งฌ Step 5: Detail Screen with Animated Hero Element
@Composable
fun ProductGridDetailScreen(product: Products) {
var animate by remember { mutableStateOf(false) }
val size by animateDpAsState(
targetValue = if (animate) 250.dp else 120.dp,
animationSpec = tween(600)
)
val textSize by animateFloatAsState(
targetValue = if (animate) 24f else 16f,
animationSpec = tween(600)
)
LaunchedEffect(Unit) {
animate = true
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Image(
painter = painterResource(product.imageRes),
contentDescription = null,
modifier = Modifier
.size(size)
.clip(RoundedCornerShape(24.dp))
)
Spacer(Modifier.height(16.dp))
Text(product.name, fontSize = textSize.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Text("Detailed description goes here.", textAlign = TextAlign.Center)
}
}
MainActivity
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
CodingComposeTheme {
HeroGridApp()
}
}
}
}
๐ฑ How it Works:
- Tapping an image navigates to a detail screen with the same image.
- The detail screen animates the image size and text — simulating a shared transition.
- It gives the illusion of continuity, like the image “moved” to the next screen.