Page Route Transitions & Custom Route Animations
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.
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 theBuildContext, the primaryAnimation<double>(values 0 → 1 as the page enters), and thesecondaryAnimation(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 thechildwidget returned bypageBuilder. This is where you applyFadeTransition,SlideTransition,ScaleTransition, or any combination.
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,
),
);
},
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.
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.