Hero Animations Reference
Hero animations create shared element transitions between screens, making elements appear to "fly" from one route to another.
Core Concept
Use two Hero widgets with matching tag in different routes. Flutter automatically animates the transition between them.
Basic Hero Animation
Simple Image Transition
Source route (list screen):
GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => const DetailScreen(),
),
);
},
child: Hero(
tag: 'hero-image',
child: Image.asset('images/thumbnail.png'),
),
)Destination route (detail screen):
Scaffold(
appBar: AppBar(title: const Text('Detail')),
body: GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Hero(
tag: 'hero-image', // Same tag!
child: Image.asset('images/thumbnail.png'),
),
),
)Custom PhotoHero Widget
Reusable hero widget for images:
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,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Image.asset(
photo,
fit: BoxFit.contain,
),
),
),
),
);
}
}Usage in source route:
class SourceScreen extends StatelessWidget {
const SourceScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Gallery')),
body: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
itemCount: 10,
itemBuilder: (context, index) {
return PhotoHero(
photo: 'images/photo_$index.png',
width: 100,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => DetailScreen(photo: 'images/photo_$index.png'),
),
);
},
);
},
),
);
}
}Usage in destination route:
class DetailScreen extends StatelessWidget {
const DetailScreen({super.key, required this.photo});
final String photo;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Detail')),
body: Center(
child: PhotoHero(
photo: photo,
width: 300,
onTap: () => Navigator.of(context).pop(),
),
),
);
}
}Hero Tag Best Practices
Using Object as Tag
For unique, consistent tags:
// Data model
class Photo {
final String url;
final String id;
Photo({required this.url, required this.id});
}
// Source
Hero(
tag: photo.id, // Use unique identifier
child: Image.network(photo.url),
)
// Destination
Hero(
tag: photo.id, // Same unique identifier
child: Image.network(photo.url),
)Using Data Object as Tag
When data object is consistent:
// Source
Hero(
tag: photo, // Photo object must be same instance or implement ==
child: Image.network(photo.url),
)
// Destination
Hero(
tag: photo, // Same Photo object
child: Image.network(photo.url),
)Important: If using object as tag, ensure proper == and hashCode implementation.
Custom Hero Flight Path
MaterialRectArcTween (Default)
Hero(
tag: 'hero-image',
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Transform.scale(
scale: animation.value,
child: child,
);
},
child: child,
);
},
createRectTween: (begin, end) {
return MaterialRectArcTween(begin: begin, end: end);
},
child: Image.asset('image.png'),
)MaterialRectCenterArcTween
Center-based interpolation (good for maintaining aspect ratio):
static RectTween _createRectTween(Rect? begin, Rect? end) {
return MaterialRectCenterArcTween(begin: begin, end: end);
}
Hero(
tag: 'hero-image',
createRectTween: _createRectTween,
child: Image.asset('image.png'),
)Custom RectTween
For complete control:
class LinearRectTween extends Tween<Rect> {
LinearRectTween({required Rect begin, required Rect end})
: super(begin: begin, end: end);
@override
Rect lerp(double t) => Rect.lerp(begin!, end!, t);
}
Hero(
tag: 'hero-image',
createRectTween: (begin, end) => LinearRectTween(begin: begin, end: end),
child: Image.asset('image.png'),
)Radial Hero Animation
Transform from circle to rectangle during transition.
RadialExpansion Widget
import 'dart:math' as math;
class RadialExpansion extends StatelessWidget {
const RadialExpansion({
super.key,
required this.maxRadius,
this.child,
}) : clipRectSize = 2.0 * (maxRadius / math.sqrt2);
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),
),
),
);
}
}Radial Photo Widget
class RadialPhoto extends StatelessWidget {
const RadialPhoto({
super.key,
required this.photo,
this.onTap,
});
final String photo;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).primaryColor.withValues(alpha: 0.25),
child: InkWell(
onTap: onTap,
child: Image.asset(
photo,
fit: BoxFit.contain,
),
),
);
}
}Complete Radial Hero Example
class RadialHeroAnimation extends StatelessWidget {
const RadialHeroAnimation({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Radial Hero')),
body: ListView(
children: List.generate(6, (index) {
final photo = 'images/photo_$index.png';
return Hero(
tag: photo,
createRectTween: _createRectTween,
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
child: child,
);
},
child: RadialExpansion(
maxRadius: 120,
child: RadialPhoto(
photo: photo,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => DetailScreen(photo: photo),
),
);
},
),
),
);
}),
),
);
}
static RectTween _createRectTween(Rect? begin, Rect? end) {
return MaterialRectCenterArcTween(begin: begin, end: end);
}
}
class DetailScreen extends StatelessWidget {
const DetailScreen({super.key, required this.photo});
final String photo;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Detail')),
body: Center(
child: Hero(
tag: photo,
createRectTween: RadialHeroAnimation._createRectTween,
child: SizedBox(
width: 300,
height: 300,
child: RadialPhoto(
photo: photo,
onTap: () => Navigator.of(context).pop(),
),
),
),
),
);
}
}Custom Placeholder During Flight
flightShuttleBuilder
Customize hero appearance during flight:
Hero(
tag: 'hero-image',
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
// fromContext - source hero's context
// toContext - destination hero's context
// direction - HeroFlightDirection.push or pop
// animation - Animation<double> for the flight
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Transform.rotate(
angle: animation.value * math.pi,
child: child,
);
},
child: child,
);
},
child: Image.asset('image.png'),
)Replace Entire Hero During Flight
Hero(
tag: 'hero-image',
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
// Show different widget during flight
return Container(
width: 100,
height: 100,
color: Colors.blue,
);
},
child: Image.asset('image.png'),
)Transition Settings
Animation Duration
MaterialApp(
// Set global hero animation duration
theme: ThemeData(
pageTransitionsTheme: PageTransitionsTheme(
builders: {
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
},
),
),
)Custom PageTransitionBuilder
class CustomHeroTransitionBuilder extends PageTransitionsBuilder {
@override
Widget buildTransitions<T>(
PageRoute<T> route,
BuildContext? secondaryContext,
Widget child,
) {
return FadeUpwardsPageTransitionsBuilder().buildTransitions(
route,
secondaryContext,
child,
);
}
}Hero Mode
Disable Hero Animations
HeroMode(
enabled: false, // Disable all child hero animations
child: ListView(
children: [
Hero(tag: 'image1', child: Image.asset('1.png')),
Hero(tag: 'image2', child: Image.asset('2.png')),
],
),
)Conditional Hero Mode
HeroMode(
enabled: !_disableAnimations,
child: Hero(tag: 'image', child: Image.asset('image.png')),
)Debugging Hero Animations
Slow Animation
void main() {
timeDilation = 10.0; // 10x slower
runApp(MyApp());
}Visualize Hero Bounds
void main() {
debugPaintSizeEnabled = true;
runApp(MyApp());
}Print Hero Tags
class DebugHero extends StatelessWidget {
const DebugHero({
super.key,
required this.tag,
required this.child,
});
final Object tag;
final Widget child;
@override
Widget build(BuildContext context) {
print('Building Hero with tag: $tag');
return Hero(tag: tag, child: child);
}
}Best Practices
DO
- Use unique, consistent tags (often the data object itself)
- Keep hero widget trees similar between routes
- Wrap images in
Materialwith transparent color for "pop" effect - Use
timeDilationto debug transitions - Consider
createRectTweenfor custom flight paths - Use
flightShuttleBuilderfor custom flight appearance
DON'T
- Use duplicate tags (conflicts!)
- Change hero structure significantly between routes (jarring transition)
- Forget
Materialwrapper (no splash effect) - Use very large images in heroes (performance)
- Ignore aspect ratio during transition (distortion)
Common Patterns
Grid to Fullscreen Image
Grid item:
PhotoHero(
photo: photo.url,
width: 150, // Smaller in grid
onTap: () => Navigator.push(..., detailScreen),
)Fullscreen:
PhotoHero(
photo: photo.url,
width: MediaQuery.of(context).size.width, // Full width
onTap: () => Navigator.pop(),
)List Header to Page Header
List:
Hero(
tag: 'header',
child: SizedBox(
height: 200,
child: Image.asset('header.jpg'),
),
)Page:
Hero(
tag: 'header',
child: SizedBox(
height: 300, // Larger on detail page
child: Image.asset('header.jpg'),
),
)Shared Element with Content Update
// In both routes
Hero(
tag: 'card',
child: Card(
child: Column(
children: [
Image.asset('image.png'),
Text(showDetails ? 'Full description...' : 'Brief description'),
],
),
),
)Performance Tips
- Optimize hero images (compress, lazy load)
- Use
RepaintBoundaryaround hero children if needed - Test on low-end devices
- Profile with Flutter DevTools Performance overlay
- Avoid complex widget trees inside hero
Accessibility
- Respect
MediaQuery.disableAnimationssetting - Consider alternative navigation for users who prefer no animations
- Ensure hero content remains accessible during transition
- Test with screen readers
Advanced Techniques
Multiple Heroes on Same Route
class PhotoDetail extends StatelessWidget {
const PhotoDetail({super.key, required this.photos});
final List<String> photos;
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Main photo hero
Positioned.fill(
child: Hero(
tag: photos[0],
child: Image.asset(photos[0]),
),
),
// Overlay hero (e.g., like button)
Positioned(
top: 16,
right: 16,
child: Hero(
tag: 'like-button',
child: Icon(Icons.favorite),
),
),
],
);
}
}Nested Heroes
Hero(
tag: 'parent',
child: Card(
child: Column(
children: [
Hero(
tag: 'child-image',
child: Image.asset('image.png'),
),
Hero(
tag: 'child-title',
child: Text('Title'),
),
],
),
),
)Hero with Scroll Views
class ScrollableHero extends StatelessWidget {
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 300,
flexibleSpace: FlexibleSpaceBar(
background: Hero(
tag: 'header-image',
child: Image.asset('header.jpg', fit: BoxFit.cover),
),
),
),
SliverList(
delegate: SliverChildListDelegate([
// Content
]),
),
],
);
}
}