Hero Animations in Flutter: Shared Element Transitions Done Right
How Flutter Hero animations fly widgets between routes: matching tags, flight mechanics with MaterialRectArcTween, custom flights with flightShuttleBuilder and createRectTween, radial heroes, and pitfalls to avoid.
Published on • September 25, 2026
AI Assistant

The shared element transition is one of the most polished effects in modern app design: tap a photo in a grid, and the photo itself glides across the screen into the details page. The user never wonders where they are or how they got there — the element is the navigation.
In Flutter this is the Hero widget, and it has been part of the framework since the early days precisely because it is hard to build by hand. This post covers how heroes work, what happens during a flight, how to customize one, and the caveats that bite people first.
The Basic Recipe
A hero animation needs exactly two things: a Hero in the outgoing route and a Hero in the incoming route, both with the same tag. When Navigator pushes the new route, the framework finds the matching pair and animates the element between them.
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, // The matching tag pairs the two heroes.
child: Material(
color: Colors.transparent, // Lets the image pop out of the background.
child: InkWell(
onTap: onTap,
child: Image.asset(photo, fit: BoxFit.contain),
),
),
),
);
}
}
Triggering the flight is an ordinary navigation:
class HeroAnimation extends StatelessWidget {
const HeroAnimation({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: PhotoHero(
photo: 'images/flippers-alpha.png',
width: 300.0,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => Scaffold(
body: Container(
color: Colors.lightBlueAccent,
alignment: Alignment.topLeft,
child: PhotoHero(
photo: 'images/flippers-alpha.png',
width: 100.0, // Smaller at the destination.
onTap: () => Navigator.of(context).pop(),
),
),
),
),
);
},
),
),
);
}
}
Note the deliberate asymmetry: width: 300 on the source, width: 100 on the destination. The hero’s bounds are interpolated between the two, so simply placing the destination hero at a different size and position is all it takes to choreograph the flight. Wrapping the image in a Material with a transparent color lets it “pop out” of any surrounding background during the transition.
What Actually Happens During a Flight
At the moment a route is pushed (t=0.0), the framework:
- Computes the destination hero’s path using curved motion per the Material motion specification.
- Places the destination hero in the application overlay — above every route, so it flies over the outgoing and incoming screens alike.
- Moves the source hero offscreen while the overlay copy animates.
The animation interpolates the hero’s rectangular bounds with a RectTween, provided by the Hero’s createRectTween property. The default is MaterialRectArcTween, which moves the rectangle’s opposing corners along a curved arc rather than a straight line — that subtle curve is what makes the motion feel Material rather than mechanical.
On completion, the hero settles into the destination route, the destination hero appears in its final position, and the source hero is restored (invisible until you pop). Popping the route reverses the whole choreography.
To inspect a flight in slow motion during development:
import 'package:flutter/scheduler.dart' show timeDilation;
void main() {
timeDilation = 5.0; // 1.0 is normal speed.
runApp(const MyApp());
}
Customizing the Flight
Two hooks customize what flies and what stays behind.
createRectTween — how bounds travel
The radial hero example in the Flutter docs swaps the default corner-based tween for a center-based one:
static RectTween _createRectTween(Rect? begin, Rect? end) {
return MaterialRectCenterArcTween(begin: begin, end: end);
}
The default corner interpolation distorts the aspect ratio during a circular-to-square transformation; interpolating the center point keeps the aspect ratio constant. Any RectTween works here, which makes createRectTween the natural place to implement straight-line or custom-eased flights.
flightShuttleBuilder — what flies
Sometimes the two endpoints should not be the same widget — a circular avatar that becomes a full-bleed image, a thumbnail that swaps to a high-resolution variant mid-flight. flightShuttleBuilder supplies the widget that actually rides in the overlay:
Hero(
tag: photo,
flightShuttleBuilder: (flightContext, animation, direction,
fromHeroContext, toHeroContext) {
return ScaleTransition(
scale: animation.drive(Tween(begin: 0.8, end: 1.0)),
child: toHeroContext.widget,
);
},
child: ...,
)
The direction parameter tells you whether it is a push or a pop, so the shuttle can adapt per direction.
placeholderBuilder — what stays behind
While the flight runs, the hero’s original spots are empty. placeholderBuilder fills the source position during the flight — commonly used to keep a subtle outline of the element in place so the underlying list does not visibly shift.
Radial Hero Animations
The most famous Flutter hero variant animates a circle into a square by intersecting two clips: a growing ClipOval (from minRadius to maxRadius) inside a constant-size ClipRect:
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),
),
),
);
}
}
Two details from the official example carry over to any custom clip-based hero:
- The
Heromust wrap theRadialExpansion, not live inside it — the hero animates the outer bounds, and the clip does the shaping. - The destination route uses
PageRouteBuilder, since custom route transitions pair naturally with custom hero geometry.
For debugging clip intersections, debugPaintSizeEnabled = true renders the layer guides over your UI.
Rules and Pitfalls
- Tags must be unique per route. Two heroes with the same tag on one route throw an error. Deriving tags from stable data (the asset path, a record id) is safer than index-based tags in lists that reorder.
- Identical widget trees fly best. The framework animates between two independently built subtrees; matched structures make the interpolation seamless.
- Transparent Material under the hero — an opaque ancestor clips the flying element into a rectangle and ruins the pop-out effect.
- InkWell splashes draw on the first Material ancestor — during flight, that is the hero’s own
Material, so wrap with aMaterialof the right shape and color. MaterialPageRoute/CupertinoPageRoutegive the standard arc;PageRouteBuilderfor everything custom.- Heroes and nested navigators need care: heroes animate within the nearest navigator by default, and crossing navigator boundaries (for example, inside a
ShellRoutewith a nestedNavigator) requires tags to match within the same navigator scope. timeDilationleaks — it is a global. Set it only in debug builds and remember to reset it.
Where Heroes Shine
Grid-to-detail is the canonical case, but the pattern generalizes: list item to expanded view, avatar to profile header, card to full-screen editor, tab icon morphing between BottomNavigationBar destinations (via IndexedStack-paired heroes). Any time two screens share a visual anchor, a hero converts an abrupt route change into a continuous spatial story — and at two widgets and a shared tag, it remains one of the best effort-to-polish ratios in the framework.