Hero Animations
Creating seamless shared element transitions between screens using Flutter's Hero widget.
Hero animations (also called shared element transitions ) are a type of animation where a widget appears to "fly" from one screen to another. You've probably seen this many times — for example, tapping a product image in a list, and watching it smoothly transition to a larger version on the details screen.
💡 Key Concept
A Hero animation creates the illusion that a widget is
shared
between
two screens. In reality, there are two separate
Hero
widgets with matching
tags
. Flutter handles the animation between them automatically.
Hero animations are implemented using two
Hero
widgets — one on the source route
and one on the destination route. Both must have the
same tag
.
// SOURCE ROUTE (List Screen)
Hero(
tag:
'product_123'
,
// Unique tag
child: Container(
width:
100
,
height:
100
,
child: Image.network(
'https://example.com/product.jpg'
),
),
)
// DESTINATION ROUTE (Detail Screen)
Hero(
tag:
'product_123'
,
// SAME tag!
child: Container(
width:
300
,
height:
300
,
child: Image.network(
'https://example.com/product.jpg'
),
),
)
✅ Key Requirements
- Same tag – Both Hero widgets must have identical tags
- Similar child widgets – The widgets should be visually similar
- Navigation triggers – The animation starts when you push/pop a route
- Unique tags – Each pair of heroes should have a unique tag
A standard hero animation flies a widget from one route to another, usually changing position and size. Here's a complete example:
class PhotoHero extends StatelessWidget {
const PhotoHero({
super.key,
required this.photo,
this.onTap,
required this.width,
});
final String photo;
final VoidCallback? onTap;
final double width;
@override
Widget build(BuildContext context) {
return SizedBox(
width: width,
child: Hero(
tag: photo,
// Use the photo path as the tag
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Image.asset(photo, fit: BoxFit.contain),
),
),
),
);
}
}
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Hero Animation')),
body: Center(
child: PhotoHero(
photo: 'assets/flippers.png',
width: 300.0,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) {
return DetailScreen(photo: 'assets/flippers.png');
},
),
);
},
),
),
);
}
}
class DetailScreen extends StatelessWidget {
final String photo;
DetailScreen({required this.photo});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Detail')),
body: Container(
color: Colors.lightBlueAccent,
padding: EdgeInsets.all(16),
alignment: Alignment.topLeft,
child: PhotoHero(
photo: photo,
width: 100.0,
onTap: () {
Navigator.of(context).pop();
},
),
),
);
}
}
💡 Debugging Tip
Use
timeDilation = 5.0
to slow down animations during development.
This makes it easier to see what's happening during the transition.
Understanding what happens behind the scenes helps you debug and optimize hero animations.
🧠 Behind the Scenes
-
Flutter calculates the
flight path
using
Tween<Rect> -
By default, uses
MaterialRectArcTweenfor curved motion - The hero is rendered in an overlay during the animation
- Both source and destination widgets remain in their routes
- The animation is reversible when you pop the route
// Custom RectTween for different flight behavior
static
RectTween
_createRectTween
(Rect? begin, Rect? end) {
return
MaterialRectCenterArcTween(begin: begin, end: end);
}
// Using in Hero widget
Hero(
tag: photo,
createRectTween: _createRectTween,
// Custom flight path
child:
// ...
)
A radial hero animation transforms the shape of the hero during flight — for example, from a circle to a square . This creates a more dramatic, polished transition.
import 'dart:math' as math;
class RadialExpansion extends StatelessWidget {
const RadialExpansion({super.key, required this.maxRadius, this.child})
: clipRectSize = 2.0 * (maxRadius / math.sqrt(2));
final double maxRadius;
final double clipRectSize;
final Widget? child;
@override
Widget build(BuildContext context) {
return ClipOval(
child: Center(
child: SizedBox(
width: clipRectSize,
height: clipRectSize,
child: ClipRect(child: child),
),
),
);
}
}
class Photo extends StatelessWidget {
const Photo({
super.key,
required this.photo,
this.color,
required this.onTap,
});
final String photo;
final Color? color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
// Slightly opaque for transparency areas
color: Theme.of(context).primaryColor.withValues(alpha: 0.25),
child: InkWell(
onTap: onTap,
child: Image.asset(photo, fit: BoxFit.contain),
),
);
}
}
class RadialHeroScreen extends StatelessWidget {
static RectTween _createRectTween(Rect? begin, Rect? end) {
return MaterialRectCenterArcTween(begin: begin, end: end);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Radial Hero')),
body: Center(
child: Hero(
tag: 'hero_photo',
createRectTween: _createRectTween,
child: RadialExpansion(
maxRadius: 150,
child: Photo(
photo: 'assets/photo.jpg',
onTap: () {
Navigator.of(context).pop();
},
),
),
),
),
);
}
}
💡 How Radial Heroes Work
- Uses ClipOval and ClipRect together
- The intersection of the two clips creates the shape change
- ClipOval scales from minRadius to maxRadius
- ClipRect maintains constant size
- The result is a circle that "grows" into a square
-
Use
MaterialRectCenterArcTweento maintain aspect ratio
Follow these steps to add hero animations to your app:
Hero
on the source screen
Hero
on the destination screen
timeDilation
to debug and adjust
If the tags don't match exactly, the hero animation won't work. The hero will just pop in/out.
Always use the exact same tag on both Hero widgets. Use a unique identifier from your data model (e.g., product ID).
If the hero's child widgets are too different, the transition can look jarring.
The heroes should have virtually identical widget trees for the smoothest animation. The same image with different sizes works well.
If you're using custom animations with controllers, forgetting to dispose them causes memory leaks.
Always call
dispose()
on any animation controllers in your stateful widgets.
If multiple heroes on the same screen share the same tag, Flutter doesn't know which one to animate.
Each pair of heroes should have a unique tag . Use the item's ID or a unique string.
📚 Learning Resources
- Flutter Hero Animations Guide – Official documentation
- Flutter Animation Docs – Complete animation guide
- Hero API Reference – Detailed API documentation
🔗 Related Lessons
- Animations – Foundation of Flutter animations
- Custom Widgets – Building reusable widgets
- Module 6 Overview – All advanced topics
🎯 Key Takeaway
Hero animations
are a powerful way to create seamless transitions between
screens in Flutter. By using two
Hero
widgets with
matching tags
,
you can create the illusion of a shared element flying between routes. Use
standard heroes
for simple position/size changes and
radial heroes
for shape transformations. Always use
unique tags
and keep the widget trees
similar for the best results.