Skip to main content
NavigationUtils allows you to customize route transition animations both globally and on a per-page basis using custom Page and Route implementations.

Overview

Custom transitions require:
  • Creating a custom Page class
  • Implementing a custom Route that reads the child at build time
  • Using pageBuilder to apply transitions globally or per-page
Do NOT use PageRouteBuilder for custom transitions. It captures the child widget in a closure at Route creation time, which prevents page updates when using grouped routes or query parameter changes. Always create a custom Route class that reads _page.child at build time.

Why custom Routes matter

Flutter’s Navigator 2 update mechanism requires Routes to read their child widget from the Page settings at build time, not at creation time. This ensures:
  • Grouped routes properly update when parameters change
  • Query parameters trigger didUpdateWidget instead of recreating widgets
  • Page state is preserved during navigation
For detailed technical documentation, see the Flutter Navigator 2 Page Update Mechanism.

Global transitions

Apply a transition to all pages by overriding the pageBuilder in DefaultRouterDelegate.
1

Create a custom Page class

Define a Page that will hold your child widget:
class ScaleTransitionPage extends Page<void> {
  final Widget child;

  const ScaleTransitionPage({
    required this.child,
    super.key,
    super.name,
    super.arguments,
  });

  @override
  Route<void> createRoute(BuildContext context) {
    return _ScaleTransitionRoute(page: this);
  }
}
2

Implement a custom Route

Create a Route that reads the child from settings at build time:
class _ScaleTransitionRoute extends PageRoute<void> {
  _ScaleTransitionRoute({required ScaleTransitionPage page})
      : super(settings: page);

  // Read from settings at build time - this is the key!
  ScaleTransitionPage get _page => settings as ScaleTransitionPage;

  @override
  Color? get barrierColor => null;

  @override
  String? get barrierLabel => null;

  @override
  bool get maintainState => true;

  @override
  Duration get transitionDuration => const Duration(milliseconds: 300);

  @override
  Widget buildPage(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation) {
    return _page.child; // Read child from CURRENT page at build time
  }

  @override
  Widget buildTransitions(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation, Widget child) {
    return ScaleTransition(
      scale: animation,
      alignment: Alignment.center,
      child: child,
    );
  }
}
The critical line is return _page.child; in buildPage(). This reads the child from the current Page settings at build time, not from a captured closure.
3

Configure NavigationManager

Override the pageBuilder to use your custom Page:
NavigationManager.init(
  mainRouterDelegate: DefaultRouterDelegate(
    navigationDataRoutes: routes,
    pageBuilder: ({
      key,
      name,
      child,
      routeData,
      globalData,
      arguments,
    }) =>
        ScaleTransitionPage(
          key: key,
          name: name,
          arguments: arguments,
          child: child,
        ),
  ),
  routeInformationParser: DefaultRouteInformationParser(),
);

Per-page transitions

Override the global transition for specific routes using the pageBuilder property in NavigationData.
1

Create a custom Page for the transition

class RightToLeftTransitionPage extends Page<void> {
  final Widget child;

  const RightToLeftTransitionPage({
    required this.child,
    super.key,
    super.name,
    super.arguments,
  });

  @override
  Route<void> createRoute(BuildContext context) {
    return _RightToLeftRoute(page: this);
  }
}
2

Implement the custom Route

class _RightToLeftRoute extends PageRoute<void> {
  _RightToLeftRoute({required RightToLeftTransitionPage page})
      : super(settings: page);

  RightToLeftTransitionPage get _page => settings as RightToLeftTransitionPage;

  @override
  Color? get barrierColor => null;

  @override
  String? get barrierLabel => null;

  @override
  bool get maintainState => true;

  @override
  Duration get transitionDuration => const Duration(milliseconds: 300);

  @override
  Widget buildPage(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation) {
    return _page.child; // Read child at build time
  }

  @override
  Widget buildTransitions(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation, Widget child) {
    return SlideTransition(
      position: Tween<Offset>(
        begin: const Offset(1.0, 0.0),
        end: Offset.zero,
      ).animate(animation),
      child: child,
    );
  }
}
3

Use in NavigationData

Apply the custom transition to specific routes:
NavigationData(
  label: 'Details',
  url: '/details',
  builder: (context, routeData, globalData) => DetailsPage(),
  pageBuilder: ({key, name, child, routeData, globalData, arguments}) {
    return RightToLeftTransitionPage(
      key: key,
      name: name,
      arguments: arguments,
      child: child,
    );
  },
)

Disabling transitions

Global: No transition Page

Create a Page with zero-duration transitions:
class NoTransitionPage extends Page<void> {
  final Widget child;

  const NoTransitionPage({
    required this.child,
    super.key,
    super.name,
    super.arguments,
  });

  @override
  Route<void> createRoute(BuildContext context) {
    return _NoTransitionRoute(page: this);
  }
}

class _NoTransitionRoute extends PageRoute<void> {
  _NoTransitionRoute({required NoTransitionPage page}) : super(settings: page);

  NoTransitionPage get _page => settings as NoTransitionPage;

  @override
  bool get opaque => true;

  @override
  bool get barrierDismissible => false;

  @override
  Color? get barrierColor => null;

  @override
  String? get barrierLabel => null;

  @override
  bool get maintainState => true;

  @override
  Duration get transitionDuration => Duration.zero;

  @override
  Duration get reverseTransitionDuration => Duration.zero;

  @override
  Widget buildPage(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation) {
    return _page.child;  // Read from CURRENT page at build time!
  }

  @override
  Widget buildTransitions(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation, Widget child) {
    return child;  // No transition animation
  }
}

Apply globally

NavigationManager.init(
  mainRouterDelegate: DefaultRouterDelegate(
    navigationDataRoutes: routes,
    pageBuilder: ({
      key,
      name,
      child,
      routeData,
      globalData,
      arguments,
    }) => NoTransitionPage(
      key: key,
      name: name,
      arguments: arguments,
      child: child,
    ),
  ),
  routeInformationParser: DefaultRouteInformationParser(),
);

Apply per-page

NavigationData(
  label: 'No Animation',
  url: '/no-animation',
  builder: (context, routeData, globalData) => NoAnimationPage(),
  pageBuilder: ({key, name, child, routeData, globalData, arguments}) {
    return NoTransitionPage(
      key: key,
      name: name,
      arguments: arguments,
      child: child,
    );
  },
)

Common transition examples

Fade transition

@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
    Animation<double> secondaryAnimation, Widget child) {
  return FadeTransition(
    opacity: animation,
    child: child,
  );
}

Slide from bottom

@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
    Animation<double> secondaryAnimation, Widget child) {
  return SlideTransition(
    position: Tween<Offset>(
      begin: const Offset(0.0, 1.0),
      end: Offset.zero,
    ).animate(CurvedAnimation(
      parent: animation,
      curve: Curves.easeOut,
    )),
    child: child,
  );
}

Rotation transition

@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
    Animation<double> secondaryAnimation, Widget child) {
  return RotationTransition(
    turns: animation,
    child: child,
  );
}

Combined scale and fade

@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
    Animation<double> secondaryAnimation, Widget child) {
  return ScaleTransition(
    scale: CurvedAnimation(
      parent: animation,
      curve: Curves.easeInOut,
    ),
    child: FadeTransition(
      opacity: animation,
      child: child,
    ),
  );
}

Advanced: Secondary animations

Use secondaryAnimation to animate the page being covered:
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
    Animation<double> secondaryAnimation, Widget child) {
  return SlideTransition(
    position: Tween<Offset>(
      begin: const Offset(1.0, 0.0),
      end: Offset.zero,
    ).animate(animation),
    child: SlideTransition(
      position: Tween<Offset>(
        begin: Offset.zero,
        end: const Offset(-0.3, 0.0),
      ).animate(secondaryAnimation),
      child: child,
    ),
  );
}
This creates a parallax effect where the previous page slides left while the new page slides in from the right.

Best practices

The Route must read _page.child in buildPage(), not capture it at creation time. This ensures didUpdateWidget is called correctly.
Keep transitions between 200-400ms for optimal user experience:
@override
Duration get transitionDuration => const Duration(milliseconds: 300);
Use curves to make animations feel more natural:
CurvedAnimation(
  parent: animation,
  curve: Curves.easeInOut,
)
Ensure your custom transitions work correctly with grouped routes that share widget instances.

Next steps

Nested tabs

Combine custom transitions with nested navigation

Cache behavior

Understand how NavigationUtils optimizes page caching

Build docs developers (and LLMs) love