Skip to content
ExponaBLOG
FLUTTER · DAY 052 / 100

Hero and page transitions: practical tips and trade-offs

Navigation that feels connected.

Expona Admin7 min read

Hero and page transitions

When users navigate between screens in your app, the experience can feel jarring or seamless depending on how you handle transitions. Hero and page transitions are the tools that make navigation feel connected—where visual elements appear to flow from one screen to another, creating continuity that guides the user's eye and makes the interaction feel intentional. In this article, I'll walk you through implementing shared element transitions with Hero widgets, building custom page transitions with PageRouteBuilder and go_router, choosing transitions that match your platform's feel, and ensuring your animations stay snappy enough to feel responsive rather than sluggish.

Understanding Hero widgets and shared element transitions

A Hero widget wraps a child and assigns it a unique tag. When you navigate to another screen where another Hero widget shares the same tag, Flutter automatically animates that widget from its position on the first screen to its position on the second screen. The illusion is that the element "flew" between screens—this is called a shared element transition.

I've used this pattern extensively in e-commerce apps. Imagine a product list where tapping a product card takes you to its detail page. Without a Hero, the product image suddenly appears on the detail screen. With a Hero, it smoothly scales and repositions itself from the list into the detail view.

Here's the basic setup:

dart
// List screen
GestureDetector(
  onTap: () {
    Navigator.push(context, MaterialPageRoute(
      builder: (context) => ProductDetailPage(productId: product.id),
    ));
  },
  child: Hero(
    tag: 'product-${product.id}',
    child: Image.network(product.imageUrl),
  ),
)

// Detail screen
Hero(
  tag: 'product-${product.id}',
  child: Image.network(product.imageUrl),
)

The tag must be unique per Hero pair on a screen. I recommend using a stable identifier like a product ID rather than an index, because indexes can change when lists are reordered or filtered.

By default, Hero uses a linear animation and a MaterialRectAristaTransition. For most cases, this works well. If you need more control, you can provide a custom flightShuttleBuilder to customize how the Hero animates mid-flight:

dart
Hero(
  tag: 'product-${product.id}',
  flightShuttleBuilder: (context, animation, direction, fromContext, toContext) {
    return ScaleTransition(
      scale: animation,
      child: Image.network(product.imageUrl),
    );
  },
  child: Image.network(product.imageUrl),
)

One key thing I learned: the widgets wrapped by the two Hero tags must have compatible layouts. If one is square and the other is a tall rectangle, the animation can look distorted. Test with actual sizes in your design.

Custom page transitions with PageRouteBuilder

PageRouteBuilder gives you fine-grained control over how a new page enters and exits the screen. Instead of relying on platform defaults, you define the animation curve, duration, and build the transition yourself.

I used this recently in a ride-hailing app where we needed a unique bottom-up slide transition for the ride details sheet. Here's a practical example:

dart
class SlideUpRoute extends PageRouteBuilder {
  final Widget page;

  SlideUpRoute({required this.page})
      : super(
          pageBuilder: (context, animation, secondaryAnimation) => page,
          transitionsBuilder: (context, animation, secondaryAnimation, child) {
            const begin = Offset(0.0, 1.0);
            const end = Offset.zero;
            const curve = Curves.easeInOutCubic;

            var tween = Tween(begin: begin, end: end)
                .chain(CurveTween(curve: curve));

            return SlideTransition(
              position: animation.drive(tween),
              child: child,
            );
          },
          transitionDuration: Duration(milliseconds: 300),
        );
}

Then navigate with it:

dart
Navigator.push(context, SlideUpRoute(page: RideDetailsPage()));

The transitionsBuilder callback receives the animation value (which goes from 0 to 1 during the transition) and the child widget. You can combine multiple transitions—scale, rotate, opacity, position—to build rich animations. The secondaryAnimation is useful when you're popping a route and want to animate the previous screen back in.

For more complex scenarios, I often define a separate class and reuse it across the app. This keeps transitions consistent and your code DRY.

Leveraging go_router for platform-aware transitions

If you're using go_router (which I recommend for larger apps), you can define custom transitions at the route level. This is cleaner than creating PageRouteBuilder instances throughout your codebase.

dart
GoRouter(
  routes: [
    GoRoute(
      path: '/products',
      builder: (context, state) => ProductListPage(),
    ),
    GoRoute(
      path: '/products/:id',
      pageBuilder: (context, state) {
        return CustomTransitionPage(
          key: state.pageKey,
          child: ProductDetailPage(
            productId: state.pathParameters['id']!,
          ),
          transitionsBuilder: (context, animation, secondaryAnimation, child) {
            return FadeTransition(
              opacity: animation,
              child: child,
            );
          },
          transitionDuration: Duration(milliseconds: 250),
        );
      },
    ),
  ],
);

Navigator.of(context).go('/products/123');

Using go_router's CustomTransitionPage, you can define per-route transitions declaratively. This scales well when you have dozens of routes and want consistency across the navigation graph.

Choosing platform-appropriate transitions

iOS and Android have different navigation expectations. iOS traditionally uses a right-to-left slide with a subtle back swipe gesture. Android uses a bottom-up fade or bottom-up slide. Flutter's Material and Cupertino packages handle this for you by default, but when you build custom transitions, you need to be intentional.

I recommend using the defaultTargetPlatform check to apply platform-specific behavior:

dart
class PlatformAwareTransitionPage extends PageRouteBuilder {
  final Widget page;

  PlatformAwareTransitionPage({required this.page})
      : super(
          pageBuilder: (context, animation, secondaryAnimation) => page,
          transitionsBuilder: (context, animation, secondaryAnimation, child) {
            if (defaultTargetPlatform == TargetPlatform.iOS) {
              // iOS: slide from right
              const begin = Offset(1.0, 0.0);
              const end = Offset.zero;
              var tween = Tween(begin: begin, end: end)
                  .chain(CurveTween(curve: Curves.easeInOutCubic));
              return SlideTransition(
                position: animation.drive(tween),
                child: child,
              );
            } else {
              // Android: fade and scale up
              return FadeTransition(
                opacity: animation,
                child: ScaleTransition(
                  scale: Tween(begin: 0.95, end: 1.0)
                      .animate(CurvedAnimation(
                    parent: animation,
                    curve: Curves.easeOutCubic,
                  )),
                  child: child,
                ),
              );
            }
          },
          transitionDuration: Duration(milliseconds: 300),
        );
}

This respects user expectations: iOS users expect a horizontal slide, Android users expect fade or scale. When your transitions match the platform feel, the app feels native and responsive.

Keeping transitions fast and responsive

A transition that takes 500ms or longer starts to feel sluggish and makes the app seem unresponsive. From my experience shipping production apps, the sweet spot is 200–300 milliseconds. Fast enough to feel snappy, slow enough to not confuse the user's eye about what just happened.

When setting transitionDuration, aim for this range:

  • Simple transitions (fade, scale): 200–250ms
  • Complex transitions (combined animations): 250–300ms
  • Hero animations: 300ms (default Flutter uses)

Performance matters too. If your page's build() method is heavy, the animation will stutter because Flutter has to render the transition and the new page's widgets simultaneously. Profile with DevTools before and after adding transitions. Look for dropped frames.

In a travel app I worked on, we added a fancy parallax transition that looked great in isolation but dropped frames because the detail page had 50+ widgets. We simplified the transition to a 250ms fade and the jank disappeared. Sometimes the best transition is the one that doesn't fight the renderer.

Common mistakes

  • Mismatched Hero tags: If the tags don't match exactly (capitalization, format), the Hero animation won't trigger and you'll see a standard navigation. Always double-check your tag strings, especially when using dynamic IDs.
  • Hero conflicts on the same screen: Two Hero widgets with the same tag on a single screen will cause a runtime error. Use unique, stable identifiers per Hero pair.
  • Overly long transitions: A 500ms+ transition might impress for a moment, but users quickly perceive it as lag. Stick to 200–350ms for most cases.
  • Ignoring frame rate: Building transitions without checking for dropped frames can make your app feel janky. Always profile with DevTools, especially on lower-end devices.
  • Incompatible widget layouts in Hero pairs: If the Hero child has different aspect ratios or sizes on each screen, the animation looks distorted. Ensure the wrapped widgets have compatible dimensions.
  • Forgetting to test back navigation: Implement secondaryAnimation in custom transitions so your previous screen animates back in smoothly when the user pops the route.

Key takeaways

  • Hero widgets create shared element transitions by matching tags across screens; use stable identifiers like IDs rather than indexes.
  • PageRouteBuilder and go_router's CustomTransitionPage give you full control over page entrance and exit animations.
  • Match your transitions to the platform's expectations: iOS prefers horizontal slides, Android prefers fades and scales.
  • Keep transitions between 200–350ms to feel responsive; longer durations make the app feel sluggish.
  • Profile your transitions on real devices to ensure no dropped frames, especially on detail pages with many widgets.
  • Implement secondaryAnimation for smooth back navigation when users pop routes.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles