Flutter custom page route transitions: clean patterns and reusable code

Build polished, brand-aligned navigation with Flutter custom page route transitions. Examples, reusable code, theming, performance, and testing tips.

ASOasis
7 min read
Flutter custom page route transitions: clean patterns and reusable code

Image used for representation purposes only.

Why custom transitions matter

Out-of-the-box, Flutter gives you platform-consistent transitions (Cupertino on iOS, Material on Android). That’s great for familiarity, but product teams often want distinct motion that reflects brand personality, clarifies hierarchy, or reduces perceived latency. Building custom page route transitions in Flutter is straightforward once you understand how Navigator, Route, Animation, and the transitionsBuilder work together.

This guide covers:

  • The route/animation primitives that power transitions
  • Quick, copy‑pasteable examples (fade, slide, scale, combined)
  • A reusable custom PageRoute
  • Page-based navigation (Navigator 2.0) with custom transitions
  • App-wide transition theming
  • Accessibility, performance, and testing tips

Key concepts in 60 seconds

  • Navigator: Manages a stack of routes (pages). Pushing shows a new page. Popping removes it.
  • Route: Describes a screen and its transition. MaterialPageRoute and CupertinoPageRoute are built-in routes with default transitions.
  • PageRouteBuilder: A configurable route that lets you define exactly how the transition should animate.
  • Animation: A value that goes from 0.0 to 1.0 during the transition. You drive visual changes (opacity, position, scale) with it.
  • transitionsBuilder: A function that converts the animation value(s) into widgets like FadeTransition, SlideTransition, etc.

The minimal example (Fade)

Use PageRouteBuilder and return a transition widget inside transitionsBuilder.

Navigator.of(context).push(
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => const DetailsPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return FadeTransition(opacity: animation, child: child);
    },
    transitionDuration: const Duration(milliseconds: 250),
    reverseTransitionDuration: const Duration(milliseconds: 200),
  ),
);

Add easing and a slide for more personality

Curves shape your motion. Combine SlideTransition and FadeTransition with a CurvedAnimation.

Navigator.of(context).push(
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => const DetailsPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      final curved = CurvedAnimation(
        parent: animation,
        curve: Curves.easeOutCubic,
        reverseCurve: Curves.easeInCubic,
      );

      final offsetTween = Tween<Offset>(
        begin: const Offset(0.06, 0.0), // subtle slide in from right
        end: Offset.zero,
      );

      return FadeTransition(
        opacity: curved,
        child: SlideTransition(
          position: offsetTween.animate(curved),
          child: child,
        ),
      );
    },
  ),
);

A reusable CustomPageRoute

Create a drop‑in route you can use across your app. This example combines a subtle slide + fade and exposes common knobs.

class CustomPageRoute<T> extends PageRouteBuilder<T> {
  CustomPageRoute({
    required Widget child,
    RouteSettings? settings,
    Duration duration = const Duration(milliseconds: 280),
    bool fullscreenDialog = false,
    bool maintainState = true,
    Curve curve = Curves.easeOutCubic,
  }) : super(
          settings: settings,
          transitionDuration: duration,
          reverseTransitionDuration: duration,
          fullscreenDialog: fullscreenDialog,
          maintainState: maintainState,
          pageBuilder: (context, animation, secondaryAnimation) => child,
          transitionsBuilder: (context, animation, secondaryAnimation, child) {
            final curved = CurvedAnimation(
              parent: animation,
              curve: curve,
              reverseCurve: Curves.easeInCubic,
            );
            return FadeTransition(
              opacity: curved,
              child: SlideTransition(
                position: Tween<Offset>(
                  begin: const Offset(0.04, 0),
                  end: Offset.zero,
                ).animate(curved),
                child: child,
              ),
            );
          },
        );
}

// Usage
Navigator.of(context).push(
  CustomPageRoute(child: const DetailsPage(), settings: const RouteSettings(name: 'details')),
);

Tips:

  • Tune the offset to 0.02–0.08 for a premium, understated look.
  • Keep durations short (200–320 ms) to feel snappy.

Direction-aware polish with secondaryAnimation

secondaryAnimation progresses as the route below the incoming route animates. Use it to reduce visual conflict by slightly dimming or shifting the outgoing page.

transitionsBuilder: (context, animation, secondaryAnimation, child) {
  final incoming = CurvedAnimation(
    parent: animation,
    curve: Curves.easeOut,
    reverseCurve: Curves.easeIn,
  );

  // Nudge outgoing page back a touch while we bring the new one in
  final outgoingShift = Tween<Offset>(begin: Offset.zero, end: const Offset(-0.02, 0))
      .animate(CurvedAnimation(parent: secondaryAnimation, curve: Curves.easeOut));

  return SlideTransition(
    position: Tween<Offset>(begin: const Offset(0.08, 0), end: Offset.zero).animate(incoming),
    child: FadeTransition(
      opacity: incoming,
      child: SlideTransition(position: outgoingShift, child: child),
    ),
  );
}

Note: Each route controls only its own child. The subtle outgoingShift uses the current route’s secondaryAnimation when this route is on top of another.

Page-based navigation (Navigator 2.0) with custom transitions

If you’re using the declarative, page-based API, create a Page that returns a PageRouteBuilder.

class FadeTransitionPage<T> extends Page<T> {
  final Widget child;
  const FadeTransitionPage({required this.child, super.key, super.name, super.arguments});

  @override
  Route<T> createRoute(BuildContext context) {
    return PageRouteBuilder<T>(
      settings: this,
      pageBuilder: (context, animation, secondaryAnimation) => child,
      transitionsBuilder: (context, animation, secondaryAnimation, child) {
        return FadeTransition(opacity: animation, child: child);
      },
      transitionDuration: const Duration(milliseconds: 220),
    );
  }
}

// Inside a Navigator with pages:
Navigator(
  pages: [
    FadeTransitionPage(name: 'home', child: const HomePage()),
    if (showDetails)
      FadeTransitionPage(name: 'details', child: const DetailsPage()),
  ],
  onPopPage: (route, result) => route.didPop(result),
)

App-wide transition theming

You can swap default transitions for the entire app using pageTransitionsTheme. This is useful when you want consistent motion across routes without wrapping every push.

MaterialApp(
  theme: ThemeData(
    pageTransitionsTheme: const PageTransitionsTheme(
      builders: <TargetPlatform, PageTransitionsBuilder>{
        TargetPlatform.android: ZoomPageTransitionsBuilder(),
        TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
        TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
        TargetPlatform.windows: ZoomPageTransitionsBuilder(),
        TargetPlatform.linux: ZoomPageTransitionsBuilder(),
      },
    ),
  ),
  home: const HomePage(),
);

You can also supply a custom PageTransitionsBuilder for a truly bespoke look:

class MinimalFadePageTransitionsBuilder extends PageTransitionsBuilder {
  const MinimalFadePageTransitionsBuilder();
  @override
  Widget buildTransitions<T>(
    PageRoute<T> route,
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
    Widget child,
  ) {
    return FadeTransition(opacity: animation, child: child);
  }
}

// Apply it globally
ThemeData(
  pageTransitionsTheme: const PageTransitionsTheme(
    builders: { TargetPlatform.android: MinimalFadePageTransitionsBuilder() },
  ),
);

Advanced: shared-axis and fade-through (official animations package)

For polished, spec-aligned transitions, use the official animations package, which includes SharedAxisTransition and FadeThroughTransition.

import 'package:animations/animations.dart';

PageRouteBuilder(
  pageBuilder: (context, animation, secondaryAnimation) => const DetailsPage(),
  transitionsBuilder: (context, animation, secondaryAnimation, child) {
    return SharedAxisTransition(
      animation: animation,
      secondaryAnimation: secondaryAnimation,
      transitionType: SharedAxisTransitionType.horizontal,
      child: child,
    );
  },
);

Accessibility: respect reduced motion

Some users prefer fewer animations. Check MediaQuery and shorten or disable transitions when appropriate.

final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
final duration = reduceMotion ? const Duration(milliseconds: 0) : const Duration(milliseconds: 260);

Navigator.of(context).push(
  PageRouteBuilder(
    transitionDuration: duration,
    reverseTransitionDuration: duration,
    pageBuilder: (c, a, s) => const DetailsPage(),
    transitionsBuilder: (c, a, s, child) => reduceMotion ? child : FadeTransition(opacity: a, child: child),
  ),
);

Performance and UX tips

  • Prefer subtle offsets (≤ 0.10) and short durations (200–320 ms). Long or large movements can feel sluggish.
  • Reuse tweens and curves (static const) where possible to avoid unnecessary allocations.
  • Avoid animating expensive layouts; wrap complex backgrounds with RepaintBoundary if needed.
  • Keep Hero animations orthogonal to route transitions; test combinations to prevent jank or conflicting motion.
  • Use maintainState: false for routes with heavy trees that don’t need to persist offscreen.

Testing your transitions

Widget tests can verify that transitions run and settle as expected.

testWidgets('navigates with custom transition', (tester) async {
  await tester.pumpWidget(const MyApp());

  expect(find.text('Home'), findsOneWidget);

  await tester.tap(find.text('Open details'));
  await tester.pump(); // start animation frame

  // Midway through transition
  await tester.pump(const Duration(milliseconds: 140));
  expect(find.byType(DetailsPage), findsOneWidget);

  // Finish transition
  await tester.pumpAndSettle();
  expect(find.text('Details'), findsOneWidget);
});

Troubleshooting gotchas

  • Black flashes or abrupt changes: Ensure your transitionsBuilder always returns the provided child wrapped in transitions; avoid rebuilding a different widget tree.
  • Double animations: Don’t nest multiple Navigator instances unless you intend to animate independently (e.g., bottom tabs). If you must, manage transitions per navigator.
  • Dialog vs page: For overlays, consider showGeneralDialog with its own transition rather than pushing a full-screen route.
  • Gesture vs animation: On iOS, interactive back-swipe uses the route’s transition; ensure your curve and reverseCurve feel right when scrubbed by gesture.

Copy-paste starter kit

// 1) Push helper
Future<T?> pushFadeSlide<T>(BuildContext context, Widget page) {
  return Navigator.of(context).push<T>(
    PageRouteBuilder<T>(
      pageBuilder: (c, a, s) => page,
      transitionsBuilder: (c, a, s, child) {
        final curved = CurvedAnimation(parent: a, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic);
        return FadeTransition(
          opacity: curved,
          child: SlideTransition(
            position: Tween<Offset>(begin: const Offset(0.05, 0), end: Offset.zero).animate(curved),
            child: child,
          ),
        );
      },
      transitionDuration: const Duration(milliseconds: 260),
      reverseTransitionDuration: const Duration(milliseconds: 220),
    ),
  );
}

// 2) Reusable route with parameters
class BrandRoute<T> extends PageRouteBuilder<T> {
  BrandRoute({
    required WidgetBuilder builder,
    RouteSettings? settings,
    this.curve = Curves.easeOutCubic,
    this.offset = const Offset(0.04, 0),
    this.duration = const Duration(milliseconds: 260),
  }) : super(
          settings: settings,
          transitionDuration: duration,
          reverseTransitionDuration: duration,
          pageBuilder: (c, a, s) => builder(c),
          transitionsBuilder: (c, a, s, child) {
            final curved = CurvedAnimation(parent: a, curve: curve, reverseCurve: Curves.easeInCubic);
            return FadeTransition(
              opacity: curved,
              child: SlideTransition(
                position: Tween<Offset>(begin: offset, end: Offset.zero).animate(curved),
                child: child,
              ),
            );
          },
        );

  final Curve curve;
  final Offset offset;
  final Duration duration;
}

Wrap-up

Custom page route transitions in Flutter are ergonomic and powerful: PageRouteBuilder gives you precise control, Page-based APIs let you embed motion declaratively, and pageTransitionsTheme sets a global baseline. Start small with a tasteful fade + slide, honor reduced motion settings, and standardize your look with a reusable route so every new screen inherits the same confident, brand-aligned motion.

Related Posts