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
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 );
}
}
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,
);
}
}
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,
);
}
}
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 short Transitions 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 brand Custom transitions are a great way to reinforce your app’s brand identity. Choose transitions that match your app’s style and personality.
Test performance Complex transitions can impact performance, especially on lower-end devices. Always test on real devices and consider providing a “reduced motion” option for accessibility.
Accessibility considerations Some users may have motion sensitivity. Respect the system’s accessibilityFeatures.disableAnimations setting: if ( MediaQuery . of (context).disableAnimations) {
return child; // No animation
}
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