Skip to main content
NavigationUtils allows you to customize route transition animations both globally and on a per-page basis. Create smooth, branded experiences with custom transitions.
Important: 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 the child at build time.

Global transitions

Define a global transition that applies to all routes unless overridden.

Create custom Page and Route classes

1

Define a custom Page class

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

Create a custom Route that reads child 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,
    );
  }
}
3

Override the pageBuilder

NavigationManager.init(
  mainRouterDelegate: DefaultRouterDelegate(
    navigationDataRoutes: routes,
    pageBuilder: ({
      key,
      name,
      child,
      routeData,
      globalData,
      arguments,
    }) =>
        ScaleTransitionPage(
          key: key,
          name: name,
          arguments: arguments,
          child: child,
        ),
  ),
  routeInformationParser: DefaultRouteInformationParser(),
);
This applies a scale transition to all pages globally.
The critical part is reading _page.child at build time in buildPage(). This ensures didUpdateWidget() is called correctly when the page updates.

Per-page transitions

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

Create a custom transition

// Define a custom Page for your per-page 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);
  }
}

// Custom Route that reads child at BUILD TIME
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,
    );
  }
}

Use in NavigationData

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,
    );
  },
),
The DetailsPage now has a right-to-left slide transition, overriding any global transition.

Disable transitions

Globally

Create a no-transition Page and Route:
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(),
);

Per-page

Disable transitions for specific routes:
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(animation),
    child: child,
  );
}

Rotation transition

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

Combined transitions

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

Custom duration and curves

Customize the transition duration and curve:
class _CustomRoute extends PageRoute<void> {
  // ...

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

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

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

Platform-specific transitions

Use different transitions based on platform:
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
    Animation<double> secondaryAnimation, Widget child) {
  if (Theme.of(context).platform == TargetPlatform.iOS) {
    // iOS-style slide from right
    return SlideTransition(
      position: Tween<Offset>(
        begin: const Offset(1.0, 0.0),
        end: Offset.zero,
      ).animate(animation),
      child: child,
    );
  } else {
    // Material fade through
    return FadeTransition(
      opacity: animation,
      child: child,
    );
  }
}

Best practices

Keep transitions shortTransitions longer than 300-400ms can feel sluggish. Aim for:
  • Fast navigation: 200-300ms
  • Standard navigation: 300-400ms
  • Dramatic effects: 400-500ms (use sparingly)
Match your brandCustom transitions are a great way to reinforce your app’s brand identity. Choose transitions that match your app’s style and personality.
Test performanceComplex transitions can impact performance, especially on lower-end devices. Always test on real devices and consider providing a “reduced motion” option for accessibility.
Accessibility considerationsSome users may have motion sensitivity. Respect the system’s accessibilityFeatures.disableAnimations setting:
if (MediaQuery.of(context).disableAnimations) {
  return child; // No animation
}

Why avoid PageRouteBuilder

The README specifically warns against using PageRouteBuilder. Here’s why:
// ❌ Wrong - captures child at creation time
PageRouteBuilder(
  pageBuilder: (context, animation, secondaryAnimation) => child,
  // child is captured in closure - won't update!
)

// ✅ Correct - reads child at build time
class _CustomRoute extends PageRoute<void> {
  CustomPage get _page => settings as CustomPage;
  
  @override
  Widget buildPage(...) {
    return _page.child; // Always reads current child
  }
}
When using grouped routes or query parameters, the page widget needs to update. PageRouteBuilder captures the widget in a closure, preventing updates. Custom Route classes read the child at build time, ensuring updates work correctly.

Next steps

URL aliases

Learn about grouped routes that share widget instances

Nested navigation

Implement nested navigators with custom transitions

Build docs developers (and LLMs) love