skip to content

Hero Flights

A Hero with the same tag on two routes flies between their positions during navigation, drawn in the overlay by a HeroController. Interviewers ask why a hero silently fails to animate.

part ofFlutteroverview, primer and where to startread it →
on this pageshow

explore

questions

5

In Flutter, how does a Hero animation carry a widget between two routes, and what must the two Hero widgets share?

level: juniorimportance: must knowfreq 55%

answer

  1. same tag on both routes
  2. both routes must be PageRoutes
  3. HeroController observes the Navigator
  4. flies in the Navigator's overlay
  5. destination hero's child is shown

basics

~20 s

The app's HeroController pairs Hero widgets with equal tags on the outgoing and incoming PageRoutes. During the route transition, the destination hero's child flies in the Navigator's overlay between their bounds while placeholders fill both original slots.

solid answer

~40 s

Wrap the shared visual on both screens in `Hero(tag: ..., child: ...)` with **equal** tags - usually an id such as `'product-42'`. When a `PageRoute` is pushed or popped, the `HeroController` that `MaterialApp` or `CupertinoApp` installs collects the heroes on both routes, pairs them by tag, and for each pair lifts a *shuttle* into the Navigator's `Overlay`. By default the shuttle is the destination hero's child; it starts at the source hero's global rectangle and ends at the destination's, driven by the route's own transition animation curved with `Hero.curve` (default `Curves.fastOutSlowIn`). While it flies, the originals are replaced by placeholders of the same size. `MaterialApp`'s controller moves the rectangle along an arc (`MaterialRectArcTween`), `CupertinoApp`'s in a straight line. Tags must be unique within each route.

code

dart · 51 lines
dart
import 'package:flutter/material.dart';

class Product {
  const Product({required this.id, required this.name, required this.thumbnailUrl});
  final String id;
  final String name;
  final String thumbnailUrl;
}

class ProductTile extends StatelessWidget {
  const ProductTile({super.key, required this.product});
  final Product product;

  @override
  Widget build(BuildContext context) {
    return InkWell(
      onTap: () => Navigator.of(context).push(
        MaterialPageRoute<void>(
          builder: (BuildContext context) => ProductDetailPage(product: product),
        ),
      ),
      child: Hero(
        tag: 'product-${product.id}',
        child: Image.network(product.thumbnailUrl, fit: BoxFit.cover),
      ),
    );
  }
}

class ProductDetailPage extends StatelessWidget {
  const ProductDetailPage({super.key, required this.product});
  final Product product;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(product.name)),
      body: Column(
        children: <Widget>[
          AspectRatio(
            aspectRatio: 4 / 3,
            child: Hero(
              tag: 'product-${product.id}',
              child: Image.network(product.thumbnailUrl, fit: BoxFit.cover),
            ),
          ),
        ],
      ),
    );
  }
}

go deeper

for a junior

Recall that both screens wrap the shared element in Hero with an equal tag, and that pushing a MaterialPageRoute is enough to trigger the flight.

for a middle

Explain the HeroController pairing heroes by tag, the shuttle in the Navigator's overlay, the placeholders, and why only PageRoutes take part.

for a senior

Design tags from stable ids, keep the destination hero available on the first frame, and choose between arc and linear paths deliberately.

for a principal

Decide where shared-element transitions belong in an app's navigation model and how tag conventions stay consistent across teams and features.

## What a Hero animation is A **hero animation** is a transition in which one visual element appears to travel from one screen to the next - a furniture store's product thumbnail growing into the large photo at the top of its detail page. In Flutter the element is not actually moved between screens. Two separate widgets exist, one on each route, and the framework draws a temporary third copy that flies between them. The widget that marks a candidate is **`Hero`**. Its required parameters are `tag` (an `Object`) and `child`. ## What the two heroes must share 1. **An equal `tag`.** Tags are compared with `==`, as map keys. A stable value such as `'product-${product.id}'` is safest; an object without value equality only matches the very same instance. 2. **Both routes must be `PageRoute`s.** `MaterialPageRoute`, `CupertinoPageRoute` and `PageRouteBuilder` qualify; dialogs and modal bottom sheets use `PopupRoute`s and never get hero flights. 3. **A `HeroController` observing the Navigator.** `MaterialApp` and `CupertinoApp` provide one for their root Navigator through a `HeroControllerScope`, so in a simple app you never see it. 4. **Unique tags within each route.** Two heroes with the same tag on one route raise an error when a transition starts. For good results the two children should also look essentially the same - changes in size and aspect ratio fly well, changes in layout or composition do not. ## What happens during the flight When a `PageRoute` is pushed, the `HeroController` - a `NavigatorObserver` - is told that the top route changed. It then: - collects every `Hero` on the outgoing and incoming routes and pairs them by tag; - measures each pair's rectangles in global coordinates; - inserts a **shuttle** into the Navigator's **`Overlay`**, above both routes - by default the **destination** hero's `child`, wrapped so that `MediaQuery` padding interpolates; - replaces both original heroes with **placeholders**: an empty `SizedBox` of the hero's size, or, for the source hero of a push, its child kept `Offstage` with tickers muted; - animates the shuttle's rectangle with a `Tween<Rect?>` driven by the route's transition animation, curved by `Hero.curve`; - when the flight ends, removes the shuttle and shows the destination hero in place. Popping runs the same flight backwards. The hero's timing therefore **is** the route transition's timing: a hero never flies longer than the route takes to arrive. ## Material and Cupertino paths | App widget | Controller created by | Path of the rectangle | |---|---|---| | `MaterialApp` | `MaterialApp.createMaterialHeroController()` | `MaterialRectArcTween` - corners follow arcs | | `CupertinoApp` | `CupertinoApp.createCupertinoHeroController()` | linear `RectTween` | A single `Hero` can override the path with `createRectTween`. ## The furniture store example ```dart // Grid tile Hero( tag: 'product-${product.id}', child: Image.network(product.thumbnailUrl, fit: BoxFit.cover), ) // Detail page, built immediately on push Hero( tag: 'product-${product.id}', child: Image.network(product.thumbnailUrl, fit: BoxFit.cover), ) ``` The detail page shows the thumbnail URL it already has, so its `Hero` exists on the first frame of the transition and the flight can be measured. ## Mistakes interviewers listen for - Believing the original widget is physically moved from one route to the other. - Using a fresh object as the tag on each screen and expecting it to match. - Expecting a hero to fly into a dialog or a bottom sheet. - Thinking the flight has its own duration independent of the route transition. ## Why the tag is an Object `Hero.tag` is typed `Object`, not `String`, so any value with sensible equality can serve: a string, an `int` id, an enum value or a record such as `('product', id)`, since records compare by their fields. What matters is that the grid and the detail page compute **equal** values independently. Teams usually hide this behind one small function that both screens call, so a change of convention cannot update one side and forget the other.

  • Which widget is shown while the hero is in flight, and can you change it?
    By default it is the destination hero's `child`, placed in the Navigator's overlay at the source hero's position and animated to the destination's. You can supply a different in-flight widget with `flightShuttleBuilder`; if both heroes provide one, the destination's builder wins.
  • Why does MaterialApp's hero follow a curved path while CupertinoApp's goes straight?
    Each app creates its own `HeroController`. `MaterialApp.createMaterialHeroController()` passes a `createRectTween` that returns a `MaterialRectArcTween`, whose corners follow arcs; `CupertinoApp.createCupertinoHeroController()` passes none, so the controller falls back to a linear `RectTween`. A single `Hero` can override either with its own `createRectTween`.

saying these in an interview costs you the question

  • The original widget is moved from the old route into the new one.
  • Heroes match by widget type, so the tags can differ.
  • A hero can fly into a dialog or a modal bottom sheet.
  • The hero flight runs on its own timer, separate from the route transition.
  • Any two objects with the same fields match as tags, even without value equality.
open as a page

In Flutter, what triggers the 'multiple heroes that share the same tag within a subtree' error, and how do tag design or HeroMode fix it?

level: middleimportance: should knowfreq 30%

basics

~20 s

Two Heroes with equal tags on one route trigger it in debug builds, when a navigation starts and the HeroController collects that route's heroes. Make tags unique per slot, such as a section prefix, or wrap one copy in HeroMode(enabled: false).

open as a page

In Flutter, what do a Hero's flightShuttleBuilder, placeholderBuilder and createRectTween each customise during a hero flight?

level: middleimportance: should knowfreq 30%

basics

~10 s

flightShuttleBuilder chooses the widget drawn in flight, placeholderBuilder chooses what stays in each hero's slot while it flies, and createRectTween chooses the path its rectangle follows. Hero.curve, since Flutter 3.44, sets the timing.

open as a page

In a Flutter furniture store, what do you check when a product thumbnail wrapped in Hero silently stops flying into its detail page?

level: seniorimportance: should knowfreq 45%

basics

~20 s

A hero skips its flight silently when no pair exists at transition start: unequal tags, a destination Hero built only after data loads, a non-PageRoute or zero-duration route, HeroMode(enabled: false), or an iOS back swipe without transitionOnUserGestures.

open as a page

In Flutter, why do Heroes pushed inside a nested Navigator not fly, and how do HeroController and HeroControllerScope fix it?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

Hero flights are started by a HeroController observing one Navigator. MaterialApp's HeroControllerScope is claimed by the root Navigator, which bars Navigators below it, so a nested Navigator has none. Give it its own controller through HeroControllerScope or observers.

open as a page