Animations & Motion Design

Page Route Transitions & Custom Route Animations

16 min Lesson 10 of 13

Page Route Transitions & Custom Route Animations

Every time you navigate between screens in Flutter, a route animation plays. By default, Flutter applies a platform-specific transition: a slide-from-right on Android and a slide-from-bottom on iOS. While these defaults look polished, professional apps often need custom transitions that match their brand feel — a cross-fade, a scale pop-in, a hero-style slide, or even a complex shared-element sequence.

The key to all of this is PageRouteBuilder — a low-level route class that gives you full control over the animation when pushing and popping a route.

Note: Flutter's built-in routes (MaterialPageRoute and CupertinoPageRoute) are both subclasses of PageRoute. PageRouteBuilder is also a PageRoute subclass — it simply exposes the animation hooks as constructor callbacks instead of requiring you to subclass anything yourself.

How PageRouteBuilder Works

PageRouteBuilder accepts two critical callbacks:

  • pageBuilder — returns the destination widget (your page content). It receives the BuildContext, the primary Animation<double> (values 0 → 1 as the page enters), and the secondaryAnimation (values 0 → 1 as a new page is pushed on top of this one).
  • transitionsBuilder — wraps the page widget in animation widgets. It receives the same arguments plus the child widget returned by pageBuilder. This is where you apply FadeTransition, SlideTransition, ScaleTransition, or any combination.
Tip: Always use the provided child parameter inside transitionsBuilder rather than calling pageBuilder again. Flutter caches the page widget for performance — rebuilding it inside the transition defeats that optimisation.

Example 1 — Fade Transition

A simple cross-fade is the easiest custom transition to implement. The page fades from transparent to opaque as it enters, and fades back out when popped.

Fade Page Route

Route<void> _fadeRoute(Widget page) {
  return PageRouteBuilder<void>(
    // How long the transition lasts
    transitionDuration: const Duration(milliseconds: 400),
    reverseTransitionDuration: const Duration(milliseconds: 300),

    pageBuilder: (BuildContext context,
        Animation<double> animation,
        Animation<double> secondaryAnimation) {
      return page;
    },

    transitionsBuilder: (BuildContext context,
        Animation<double> animation,
        Animation<double> secondaryAnimation,
        Widget child) {
      // CurvedAnimation shapes the raw 0→1 progress
      final curved = CurvedAnimation(
        parent: animation,
        curve: Curves.easeIn,
      );
      return FadeTransition(opacity: curved, child: child);
    },
  );
}

// Usage
Navigator.of(context).push(_fadeRoute(const DetailsPage()));

Example 2 — Slide + Fade Combination

Combining a horizontal slide with a simultaneous fade is a popular pattern for modern apps. We compose two transition widgets — SlideTransition wraps FadeTransition:

Slide-Fade Page Route

Route<void> _slideFadeRoute(Widget page) {
  return PageRouteBuilder<void>(
    transitionDuration: const Duration(milliseconds: 350),
    pageBuilder: (context, animation, secondaryAnimation) => page,
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      // Slide from right: x offset goes from 1.0 → 0.0
      const begin = Offset(1.0, 0.0);
      const end = Offset.zero;
      final slideTween = Tween(begin: begin, end: end)
          .chain(CurveTween(curve: Curves.easeOutCubic));

      // Fade in simultaneously
      final fadeTween = Tween<double>(begin: 0.0, end: 1.0)
          .chain(CurveTween(curve: Curves.easeIn));

      return SlideTransition(
        position: animation.drive(slideTween),
        child: FadeTransition(
          opacity: animation.drive(fadeTween),
          child: child,
        ),
      );
    },
  );
}

// Push from a button tap
onPressed: () => Navigator.of(context).push(
  _slideFadeRoute(const ProfilePage()),
),

Scale (Pop-In) Transition

A scale transition makes the page appear to grow from the centre of the screen — a satisfying "pop" effect often used for modal-style routes:

Scale Pop-In Route

Route<void> _scaleRoute(Widget page) {
  return PageRouteBuilder<void>(
    transitionDuration: const Duration(milliseconds: 300),
    pageBuilder: (context, animation, __) => page,
    transitionsBuilder: (context, animation, __, child) {
      final scaleTween = Tween<double>(begin: 0.85, end: 1.0)
          .chain(CurveTween(curve: Curves.easeOutBack));

      final fadeTween = Tween<double>(begin: 0.0, end: 1.0);

      return ScaleTransition(
        scale: animation.drive(scaleTween),
        child: FadeTransition(
          opacity: animation.drive(fadeTween),
          child: child,
        ),
      );
    },
  );
}

Reusable Custom Route Class

For transitions used throughout your app, extend PageRouteBuilder into a named class so you don't repeat the boilerplate everywhere:

Reusable SlideUpRoute

class SlideUpRoute<T> extends PageRouteBuilder<T> {
  final Widget page;

  SlideUpRoute({required this.page})
      : super(
          transitionDuration: const Duration(milliseconds: 400),
          reverseTransitionDuration: const Duration(milliseconds: 300),
          pageBuilder: (context, animation, secondaryAnimation) => page,
          transitionsBuilder:
              (context, animation, secondaryAnimation, child) {
            final tween = Tween(
              begin: const Offset(0.0, 1.0), // bottom → top
              end: Offset.zero,
            ).chain(CurveTween(curve: Curves.easeOutQuart));

            return SlideTransition(
              position: animation.drive(tween),
              child: child,
            );
          },
        );
}

// Usage anywhere in the app:
Navigator.of(context).push(SlideUpRoute(page: const CheckoutPage()));

Using secondaryAnimation

The secondary animation lets the current page react when something new is pushed on top of it. For example, iOS slides the background page slightly to the left while the new page slides in from the right. You can replicate this "parallax" effect:

Parallax Secondary Animation

transitionsBuilder: (context, animation, secondaryAnimation, child) {
  // Primary: new page slides in from the right
  final enterTween = Tween(
    begin: const Offset(1.0, 0.0),
    end: Offset.zero,
  ).chain(CurveTween(curve: Curves.easeOut));

  // Secondary: this page shifts slightly left when overlaid
  final exitTween = Tween(
    begin: Offset.zero,
    end: const Offset(-0.25, 0.0),
  ).chain(CurveTween(curve: Curves.easeIn));

  return SlideTransition(
    position: animation.drive(enterTween),
    child: SlideTransition(
      position: secondaryAnimation.drive(exitTween),
      child: child,
    ),
  );
},
Warning: Overly complex or slow transitions hurt perceived performance. Keep transitionDuration between 200 ms and 500 ms for most cases. Avoid heavy computations or large widget rebuilds inside transitionsBuilder — it runs on every animation frame (typically 60–120 times per second).

Global Transitions with onGenerateRoute

To apply a single custom transition to every route in your app without modifying every Navigator.push() call, override onGenerateRoute in your MaterialApp:

App-Wide Custom Transition

MaterialApp(
  onGenerateRoute: (RouteSettings settings) {
    final Widget page = _resolvePageForRoute(settings);
    return PageRouteBuilder<void>(
      settings: settings, // pass settings so route name is preserved
      pageBuilder: (_, __, ___) => page,
      transitionsBuilder: (_, animation, __, child) {
        return FadeTransition(
          opacity: CurvedAnimation(
            parent: animation,
            curve: Curves.easeIn,
          ),
          child: child,
        );
      },
    );
  },
);

Summary

PageRouteBuilder is the backbone of custom page transitions in Flutter. The transitionsBuilder callback hands you a raw Animation<double> that you shape with Tween, CurveTween, and CurvedAnimation, then apply via FadeTransition, SlideTransition, ScaleTransition, or any combination. The secondaryAnimation enables the exiting page to animate while a new route is pushed on top. For app-wide consistency, create a named subclass or wire a shared builder through onGenerateRoute.

Key Takeaway: Custom route transitions are composed from three building blocks — a Tween (defines start and end values), a Curve (shapes the timing), and a transition widget (FadeTransition, SlideTransition, ScaleTransition). Combining these three gives you virtually any page-transition effect you can imagine.