Flutter: Elevating User Experience with Hero Animations and Page Transition Effects
Have you ever noticed how in an e-commerce app, when you touch a product image, that small picture smoothly grows and appears on the detail page? Or how a photo on Instagram opens in full screen so fluidly when you tap it? That magic is possible with Hero animations.
Animations in Flutter are one of the most powerful tools that elevate user experience from ordinary to extraordinary. In this article, we'll explore techniques that will bring cinema-quality transitions to your applications.
What is Hero Animation?
Hero animation enables a widget shared between two pages to transition smoothly. The widget "flies" seamlessly from its position on the first page to its position on the second page.
It takes its name from mythological heroes - just like heroes, the widget journeys between two worlds (pages).
Why Use Hero?
Users experience context loss during page transitions. Hero animations solve this problem:
- Visual Continuity: Users always know where they are
- Professional Look: Your app looks like an industry leader
- Low Effort, High Impact: A few lines of code, tremendous results
Anatomy of Hero
You need two things for a Hero animation:
- Same tag: Hero widgets on both pages must have the same
tagvalue - Hero widget: Wrap your widget with Hero on both pages
Here's the simplest example:
// First page
Hero(
tag: 'profile-photo',
child: CircleAvatar(
backgroundImage: NetworkImage('https://picsum.photos/200'),
radius: 30,
),
)
// Second page
Hero(
tag: 'profile-photo', // Same tag!
child: CircleAvatar(
backgroundImage: NetworkImage('https://picsum.photos/200'),
radius: 100, // Different size - Flutter automatically animates
),
)The tag is like the DNA of the animation. Flutter finds widgets with the same tag and creates an animation between them.
Live Demo
You can try these widgets in the interactive example below:
💡 If the example above doesn't load, click DartPad to run it in a new tab.
Real World Example: Product Card to Detail Page
The most common use case in e-commerce apps:
class ProductCard extends StatelessWidget {
final String productId;
final String imageUrl;
final String name;
const ProductCard({
required this.productId,
required this.imageUrl,
required this.name,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProductDetailPage(
productId: productId,
imageUrl: imageUrl,
name: name,
),
),
);
},
child: Card(
child: Column(
children: [
Hero(
tag: 'product-$productId', // Unique tag
child: Image.network(
imageUrl,
height: 150,
width: double.infinity,
fit: BoxFit.cover,
),
),
Padding(
padding: EdgeInsets.all(8),
child: Text(name, style: TextStyle(fontWeight: FontWeight.bold)),
),
],
),
),
);
}
}
class ProductDetailPage extends StatelessWidget {
final String productId;
final String imageUrl;
final String name;
const ProductDetailPage({
required this.productId,
required this.imageUrl,
required this.name,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(name)),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Hero(
tag: 'product-$productId', // Same tag!
child: Image.network(
imageUrl,
height: 300,
width: double.infinity,
fit: BoxFit.cover,
),
),
Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 16),
Text(
'Product description goes here...',
style: TextStyle(fontSize: 16),
),
],
),
),
],
),
);
}
}Customizing Hero Animation
1. Changing Animation Duration
Default duration is 300ms. Use transitionDuration to change it:
Navigator.push(
context,
PageRouteBuilder(
transitionDuration: Duration(milliseconds: 600), // Slower
pageBuilder: (context, animation, secondaryAnimation) => DetailPage(),
),
);2. Custom FlightShuttleBuilder
Control how the widget appears during "flight":
Hero(
tag: 'custom-hero',
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
// Show different widget during animation
return Material(
color: Colors.transparent,
child: ScaleTransition(
scale: animation.drive(Tween(begin: 0.0, end: 1.0)),
child: Icon(Icons.favorite, size: 100, color: Colors.red),
),
);
},
child: Icon(Icons.favorite_border),
)3. Customize Path with CreateRectTween
Determine which path the widget follows:
Hero(
tag: 'curved-hero',
createRectTween: (begin, end) {
return MaterialRectArcTween(begin: begin, end: end); // Curved path
},
child: Container(width: 100, height: 100, color: Colors.blue),
)Page Transition Animations
Hero alone is great, but what if you also customize the page transition? This is where PageRouteBuilder comes in.
SlideTransition - Sliding Transition
Pages can slide from right to left, left to right, top to bottom:
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const begin = Offset(1.0, 0.0); // Start from right
const end = Offset.zero; // End at center
const curve = Curves.easeInOut;
var tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve),
);
var offsetAnimation = animation.drive(tween);
return SlideTransition(
position: offsetAnimation,
child: child,
);
},
),
);Direction Examples:
// Right to left
const begin = Offset(1.0, 0.0);
// Left to right
const begin = Offset(-1.0, 0.0);
// Bottom to top
const begin = Offset(0.0, 1.0);
// Top to bottom
const begin = Offset(0.0, -1.0);
// Diagonal (from bottom right)
const begin = Offset(1.0, 1.0);FadeTransition - Fade Transition
iOS-style smooth transition:
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
),
);ScaleTransition - Scale Transition
Page opens from small to large:
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation, child) => NextPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
var scaleTween = Tween<double>(begin: 0.0, end: 1.0).chain(
CurveTween(curve: Curves.elasticOut),
);
return ScaleTransition(
scale: animation.drive(scaleTween),
child: child,
);
},
),
);RotationTransition - Rotation Transition
Page arrives while rotating:
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return RotationTransition(
turns: animation,
child: child,
);
},
),
);Combining Multiple Animations
The real magic happens when you combine animations:
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// Both fade and slide
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: Offset(0.0, 0.3),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
),
);Custom Transition: "Zoom Fade Slide"
The "zoom + fade + slide" combination seen in professional apps:
class ZoomFadeSlideTransition extends StatelessWidget {
final Animation<double> animation;
final Widget child;
const ZoomFadeSlideTransition({
required this.animation,
required this.child,
});
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: Offset(0, 0.1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
)),
child: ScaleTransition(
scale: Tween<double>(
begin: 0.9,
end: 1.0,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
)),
child: child,
),
),
);
}
}
// Usage:
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return ZoomFadeSlideTransition(
animation: animation,
child: child,
);
},
),
);Animation Character with Curves
Curve determines the acceleration/deceleration behavior of the animation:
// Linear - Gives mechanical feel
Curves.linear
// Slow start, speed up, slow end - Most natural
Curves.easeInOut
// Smooth start
Curves.easeIn
// Smooth end
Curves.easeOut
// Elastic - Playful feel
Curves.elasticOut
// Bounce back - Eye-catching
Curves.bounceOut
// Decelerate
Curves.decelerate
// Overshoot
Curves.anticipatePro Tip: Use Curves.easeInOut or Curves.easeOut for user interfaces. Extreme curves like Curves.bounceOut are eye-catching but can be tiring.
Performance Tips
1. AnimatedOpacity Instead of Opacity
// ❌ Bad - Rebuilds every frame
Opacity(
opacity: _controller.value,
child: HeavyWidget(),
)
// ✅ Good - Optimized rebuild
AnimatedOpacity(
opacity: isVisible ? 1.0 : 0.0,
duration: Duration(milliseconds: 300),
child: HeavyWidget(),
)2. Cache Heavy Widgets
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child, // child is already cached
);
},
),
);3. Use RepaintBoundary
Only necessary parts should be redrawn during animation:
RepaintBoundary(
child: Hero(
tag: 'image',
child: Image.network('...'),
),
)Real World Use Cases
1. Photo Gallery
GridView.builder(
itemCount: photos.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
),
itemBuilder: (context, index) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PhotoViewPage(
photoUrl: photos[index],
tag: 'photo-$index',
),
),
);
},
child: Hero(
tag: 'photo-$index',
child: Image.network(
photos[index],
fit: BoxFit.cover,
),
),
);
},
)2. Profile Photo Expansion
// List item
ListTile(
leading: GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProfilePage(user: user),
),
),
child: Hero(
tag: 'avatar-${user.id}',
child: CircleAvatar(
backgroundImage: NetworkImage(user.photoUrl),
),
),
),
title: Text(user.name),
)
// Profile page
Scaffold(
body: Column(
children: [
Hero(
tag: 'avatar-${user.id}',
child: CircleAvatar(
backgroundImage: NetworkImage(user.photoUrl),
radius: 80,
),
),
// Other info...
],
),
)3. FAB to Detail Page
FloatingActionButton(
heroTag: 'fab', // Special tag for FAB
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => CreatePostPage()),
);
},
child: Hero(
tag: 'create-icon',
child: Icon(Icons.add),
),
)Common Mistakes and Solutions
Error 1: Duplicate Hero Tag
Problem: Using the same tag for multiple Heroes.
// ❌ Wrong
ListView.builder(
itemBuilder: (context, index) {
return Hero(
tag: 'item', // All have same tag!
child: ListTile(title: Text('Item $index')),
);
},
)Solution: Use unique tags.
// ✅ Correct
ListView.builder(
itemBuilder: (context, index) {
return Hero(
tag: 'item-$index', // Each item is unique
child: ListTile(title: Text('Item $index')),
);
},
)Error 2: Hero and Material Widget Incompatibility
Problem: Strange animations when using Material widget inside Hero.
Solution: Use Material on both pages or don't use it at all.
// First page
Hero(
tag: 'card',
child: Material(
child: Card(child: ...),
),
)
// Second page
Hero(
tag: 'card',
child: Material( // Same Material wrapping
child: Card(child: ...),
),
)Error 3: Animation Too Fast or Slow
Solution: Optimal duration is between 200-400ms.
// Too fast: 100ms
// Optimal: 300ms
// Too slow: 800ms+
PageRouteBuilder(
transitionDuration: Duration(milliseconds: 300), // Ideal
...
)Bonus: Reusable Transition Widget
A helper class you can use throughout your project:
class PageTransitionHelper {
static Route createRoute(Widget page, {PageTransitionType type = PageTransitionType.fade}) {
return PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
switch (type) {
case PageTransitionType.fade:
return FadeTransition(opacity: animation, child: child);
case PageTransitionType.slide:
return SlideTransition(
position: Tween<Offset>(
begin: Offset(1.0, 0.0),
end: Offset.zero,
).animate(animation),
child: child,
);
case PageTransitionType.scale:
return ScaleTransition(
scale: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOut),
),
child: child,
);
case PageTransitionType.rotation:
return RotationTransition(turns: animation, child: child);
default:
return child;
}
},
);
}
}
enum PageTransitionType { fade, slide, scale, rotation }
// Usage:
Navigator.push(
context,
PageTransitionHelper.createRoute(
NextPage(),
type: PageTransitionType.slide,
),
);Summary
- Hero Animation: Widgets "fly" between pages (requires same
tag) - SlideTransition: Sliding effect (direction control with Offset)
- FadeTransition: Fade effect (opacity animation)
- ScaleTransition: Scale effect (grow/shrink)
- RotationTransition: Rotation effect
- PageRouteBuilder: Foundation for creating custom transitions
- Curves: Determines animation character (easeInOut, elasticOut, etc.)
- Combination: Multiple transitions can be combined
- Performance: Use AnimatedOpacity, RepaintBoundary, cache
- Unique Tags: Use different tags for each Hero
Animations are the details that enchant user experience. When used appropriately without exaggeration, they make your app look professional. Now it's your turn - bring your apps to life!