shader_transitions
GPU-accelerated, shader-based page transitions for Flutter — diamond grid, circle iris, and linear wipe with eight sweep directions, optional cover color, and a configurable hold for cinematic fade-through transitions.
Live demo: flutter-shader-transition.web.app. Try every transition in your browser and adjust its settings.

Features
- Eight shader transitions —
ShaderTransition.diamond(),.circle(),.wipe(),.clock(),.polygon(),.dissolve(),.fade(),.bars(). One sealedShaderTransitionAPI. - Shared, clear parameter vocabulary —
origin,direction,feather/softness,rotation,invert,sectors/sides/count; the same name means the same thing everywhere. - Eight sweep directions — four axis-aligned, four diagonals; all reach every corner.
- Optional cover color + hold —
TransitionCover(color:, hold:)for a cinematic fade-through (cover wipe-in → hold → page wipe-out). - Routes, widgets, and app-wide —
ShaderPageRoute(Navigator),ShaderTransitionBuilders.create(go_router/auto_route),ShaderPageTransitionsBuilder(pageTransitionsTheme),ShaderTransitionSwitcher(between widgets). - iOS swipe back — on iOS and macOS,
ShaderPageRoutepops with the platform edge swipe, scrubbing the shader transition backwards under the finger. - Lifecycle callbacks —
onStart/onComplete/onProgress; play your own sound (no audio dependency bundled). - Preload-once design —
FragmentProgramcompilation is a one-timeasynccost at app startup, not per-route. - Impeller-friendly —
#version 460 core+flutter/runtime_effect.glsl, the modern Flutter shader path.
Showcase
Each clip pushes a page with the transition, then pops it (the reverse animation). To try them yourself, open the live demo.
| Diamond | Circle iris | Wipe |
|---|---|---|
![]() |
![]() |
![]() |
| Clock | Polygon | Dissolve |
![]() |
![]() |
![]() |
| Fade | Bars | Cover + hold |
![]() |
![]() |
![]() |
Diamond — a grid of diamond cells. A soft band sweeps along the chosen direction; within the band each cell fills from its center outward, so the edge reads as a shimmer of growing diamonds rather than a hard line. cellSize controls the grid density.
Circle iris — a circular reveal that grows from the screen center outward, feathered at the edge for a clean anti-aliased ring. Duration is the only knob.
Wipe — a straight feathered edge that travels across the screen in any of eight directions (four axis-aligned, four diagonal). softness sets the feather width; 0 gives a hard edge. rotation tilts the edge.
Clock — a radial hand sweeping around origin; sectors fans it into a multi-blade pinwheel, invert reverses the direction.
Polygon — a regular n-gon iris from origin. sides: 3 is a triangle; high counts approximate CircleTransition. invert contracts instead of expands.
Dissolve — a pseudo-random per-pixel grain that fills in as progress rises; grain controls how soft the speckle fades.
Fade — a uniform cross-fade, but routed through the same pipeline so it still composes with cover and the lifecycle callbacks.
Bars — a venetian blind: count parallel bars along direction wipe in parallel.
Cover + hold — a wipe with a black TransitionCover and a 600 ms hold ("fade to black, hold, reveal new scene"): the incoming page is hidden while a flat color wipes in, holds, then wipes back out to reveal the destination.
Installation
flutter pub add shader_transitions
Or in pubspec.yaml:
dependencies:
shader_transitions: ^1.1.0
Upgrading from 0.0.x? The config API changed — see Migrating from 0.0.x.
Quick start
1. Preload shaders at app startup. Fragment program compilation is too slow to do on the first transition.
import 'package:flutter/material.dart';
import 'package:shader_transitions/shader_transitions.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await ShaderTransitions.preload();
runApp(const MyApp());
}
2. Push a route. ShaderPageRoute takes the page and any transition:
Navigator.of(context).push(
ShaderPageRoute(
page: const NextPage(),
transition: const ShaderTransition.diamond(),
),
);
Type ShaderTransition. to see all eight: .diamond(), .circle(), .wipe(), .clock(), .polygon(), .dissolve(), .fade(), .bars(). If your app is on Dart 3.10+, dot shorthands let you drop the class name:
ShaderPageRoute(page: const NextPage(), transition: .circle())
3. With go_router — wrap your page in CustomTransitionPage and pass ShaderTransitionBuilders.create. opaque: false is required so the outgoing page shows through the masked area:
GoRoute(
path: '/next',
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const NextPage(),
opaque: false, // ← required
transitionDuration: const Duration(milliseconds: 800),
transitionsBuilder: ShaderTransitionBuilders.create(
const ShaderTransition.diamond(),
),
),
),
Usage
Every transition is a ShaderTransition, created with a named constructor —
ShaderTransition.diamond(), .circle(), .wipe(), .clock(),
.polygon(), .dissolve(), .fade(), or .bars(). Each returns the
matching concrete class (DiamondTransition, CircleTransition, …), which
you can also construct directly or match on in a switch. All share
duration, an optional cover, and an invert flag.
For diamond, circle, and wipe there is also a one-call route shortcut —
ShaderTransitions.diamond(page: ...), .circle(...), .wipe(...) —
shown below.
Diamond grid
A grid of diamond cells that fan out behind a sweeping band.
Navigator.of(context).push(
ShaderTransitions.diamond(
page: const NextPage(),
direction: SweepDirection.leftToRight,
cellSize: 40.0, // diamond cell size in px (≥ 1)
duration: const Duration(milliseconds: 800),
),
);
Or build the transition yourself and pass it to ShaderPageRoute:
const ShaderTransition.diamond(
direction: SweepDirection.bottomLeftToTopRight,
cellSize: 28.0,
duration: Duration(milliseconds: 900),
)
Circle iris
Circular iris reveal. origin sets where it emanates from; invert makes
it contract instead of expand.
Navigator.of(context).push(
ShaderTransitions.circle(
page: const NextPage(),
origin: Alignment.bottomRight,
invert: true, // contracting iris
duration: const Duration(milliseconds: 700),
),
);
Wipe
Linear directional wipe with a feathered edge.
Navigator.of(context).push(
ShaderTransitions.wipe(
page: const NextPage(),
direction: SweepDirection.rightToLeft,
softness: 6.0, // feather width in px; 0 = hard edge
rotation: 0.0, // radians; tilts the edge
duration: const Duration(milliseconds: 600),
),
);
Cover color & hold (cinematic fade)
Pass a TransitionCover to fill the un-revealed area with a flat color
instead of letting the outgoing page show through. hold keeps the screen
fully covered between the wipe-in and wipe-out.
Navigator.of(context).push(
ShaderPageRoute(
page: const NextPage(),
transition: const ShaderTransition.wipe(
direction: SweepDirection.leftToRight,
duration: Duration(milliseconds: 800),
cover: TransitionCover(
color: Colors.black,
hold: Duration(milliseconds: 600), // hold on black for 600 ms
),
),
),
);
cover.hold is clamped internally to at most 75% of duration — each wipe always gets at least 12.5% of the timeline, so the cover hold can never squash the wipes into nothing.
Timeline with duration: 800 ms and cover.hold: 600 ms:
| cover wipes in | hold full cover | page wipes in |
| 100 ms | 600 ms | 100 ms |
Configuration reference
Shared by every ShaderTransition:
| Field | Type | Default |
|---|---|---|
duration |
Duration |
800 ms diamond · 700 ms circle/clock/polygon · 600 ms wipe/dissolve/bars · 400 ms fade |
cover |
TransitionCover? |
null |
invert |
bool |
false |
TransitionCover: color (Color, required), hold (Duration, default Duration.zero, clamped to ≤ 75% of duration).
Per-type fields:
| Transition | Field | Type | Default |
|---|---|---|---|
.diamond() · DiamondTransition |
cellSize |
double |
40.0 (px, min 1) |
feather |
double |
0.0 |
|
direction |
SweepDirection |
topLeftToBottomRight |
|
.circle() · CircleTransition |
origin |
Alignment |
Alignment.center |
feather |
double |
2.0 |
|
.wipe() · WipeTransition |
softness |
double |
4.0 (px; 0 = hard edge) |
direction |
SweepDirection |
leftToRight |
|
rotation |
double |
0.0 (radians) |
|
.clock() · ClockTransition |
origin |
Alignment |
Alignment.center |
sectors |
int |
1 |
|
rotation |
double |
0.0 |
|
feather |
double |
2.0 |
|
.polygon() · PolygonTransition |
origin |
Alignment |
Alignment.center |
sides |
int |
6 (min 3) |
|
feather |
double |
2.0 |
|
rotation |
double |
0.0 |
|
.dissolve() · DissolveTransition |
grain |
double |
8.0 |
.fade() · FadeShaderTransition |
(shared fields only; duration defaults to 400 ms) |
||
.bars() · BarsTransition |
count |
int |
6 |
softness |
double |
4.0 |
|
direction |
SweepDirection |
leftToRight |
SweepDirection values: topLeftToBottomRight, topRightToBottomLeft, bottomLeftToTopRight, bottomRightToTopLeft, leftToRight, rightToLeft, topToBottom, bottomToTop. Used by DiamondTransition, WipeTransition, and BarsTransition; the radial transitions (CircleTransition, ClockTransition, PolygonTransition) ignore it and use origin instead.
With go_router / auto_route / app-wide
ShaderTransitionBuilders.create(transition) returns a standard RouteTransitionsBuilder, so any router that accepts one works.
go_router:
GoRoute(
path: '/next',
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const NextPage(),
opaque: false,
transitionDuration: const Duration(milliseconds: 800),
transitionsBuilder: ShaderTransitionBuilders.create(
const ShaderTransition.diamond(direction: SweepDirection.leftToRight),
),
),
),
auto_route:
AutoRoute(
page: NextRoute.page,
customRouteBuilder: <T>(context, child, page) => PageRouteBuilder<T>(
settings: page,
opaque: false,
pageBuilder: (_, __, ___) => child,
transitionsBuilder: ShaderTransitionBuilders.create(
const ShaderTransition.circle(),
),
),
),
App-wide via ThemeData.pageTransitionsTheme:
MaterialApp(
theme: ThemeData(
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: ShaderPageTransitionsBuilder(
ShaderTransition.wipe(direction: SweepDirection.leftToRight),
),
TargetPlatform.iOS: ShaderPageTransitionsBuilder(
ShaderTransition.circle(),
),
},
),
),
)
⚠️
opaque: falseis required for the outgoing page to show through the un-revealed area.ShaderPageRoutesets it for you; with go_router/auto_route you set it on the page.ShaderPageTransitionsBuilderis best paired with acover(covered transitions don't need the route below to be visible).
Swipe back (iOS)
On iOS and macOS, ShaderPageRoute supports the platform back swipe: drag
from the leading screen edge and the transition plays in reverse under your
finger. Release past halfway (or flick) to pop; otherwise the page settles
back. Thresholds match CupertinoPageRoute, and the swipe is refused
wherever a pop would be — the first route, PopScope(canPop: false), or
mid-transition. Right-to-left locales swipe from the right edge.
onStart / onComplete stay quiet while the finger is scrubbing, so a
"whoosh" doesn't play just because the user started to swipe. onProgress
keeps reporting.
ShaderPageRoute(
page: const NextPage(),
transition: const ShaderTransition.circle(),
swipeBack: false, // null (default) = on for iOS/macOS, off elsewhere
)
The swipe is specific to ShaderPageRoute (and the ShaderTransitions.*
shortcuts). go_router's CustomTransitionPage and
ShaderPageTransitionsBuilder don't own the route's animation, so they keep
the plain back button.
Widget transitions
ShaderTransitionSwitcher runs a transition between two widgets instead of
routes — an AnimatedSwitcher analog. Key the children so swaps are detected:
ShaderTransitionSwitcher(
transition: const ShaderTransition.circle(),
child: KeyedSubtree(
key: ValueKey(index),
child: pages[index],
),
)
Sound on transition
The package bundles no audio. Use the onStart / onComplete / onProgress
callbacks (available on ShaderPageRoute, ShaderTransitionBuilders.create,
and ShaderTransitionSwitcher) and play audio with whatever package you
already use:
Navigator.of(context).push(
ShaderPageRoute(
page: const NextPage(),
transition: const ShaderTransition.wipe(),
onStart: () => audioPlayer.play(AssetSource('whoosh.mp3')),
),
);
Migrating from 0.0.x
0.1.0 replaced the single ShaderTransitionConfig with a sealed
ShaderTransition hierarchy.
| 0.0.x | 0.1.0 |
|---|---|
ShaderTransitionConfig.diamond(size: 40, transitionDuration: d) |
DiamondTransition(cellSize: 40, duration: d) |
ShaderTransitionConfig.wipe(size: 6) |
WipeTransition(softness: 6) |
ShaderTransitionConfig.circle() |
CircleTransition() |
color: Colors.black, coverDuration: h |
cover: TransitionCover(color: Colors.black, hold: h) |
ShaderPageRoute(config: c) |
ShaderPageRoute(transition: t) |
ShaderTransitionBuilders.create(config) |
ShaderTransitionBuilders.create(transition) |
The ShaderTransitions.{diamond,circle,wipe}(...) convenience factories keep working — only their parameter names changed (size→cellSize, transitionDuration→duration, etc.).
How it works
The core is ShaderMask(blendMode: BlendMode.dstIn) wrapping the incoming page — the shader writes alpha into BlendMode.dstIn, so where the shader's alpha is 0 the page is transparent and the outgoing route shows through, and where it's 1 the page is opaque.
Once the animation settles (animation.value >= 1.0) the mask is dropped entirely and the raw page is painted. So a shader whose alpha lands a hair below 1.0 at full progress — dissolve grain, the clock/bars seams, a wipe's trailing edge — can never leave a translucent "ghost" of the outgoing page behind.
When cover != null the widget owns two FragmentShader instances and composes a three-phase Stack:
animation.value: 0 ─────── phase1End ───── phase2End ─────── 1
│ │ │ │
cover wipe in │░░░░░░░░░░░░░│ full cover │ full cover │
│ │ │ │
page wipe out │ hidden │ hidden │░░░░░░░░░░░░░░│
│ │ │ │
visible result: outgoing → cover full cover cover → new page
phase1End and phase2End are derived from duration and cover.hold (the hold is first clamped to ≤ 75% of duration, so each wipe always keeps at least 12.5% of the timeline). With cover.hold: Duration.zero, phases 1 and 3 abut directly — a continuous cross-fade through the color.
Used by
- Your project here — open a PR adding it!
If you ship a published app or open-source project that uses shader_transitions, please send a PR adding it to this list. A short description and a link is enough.
Contributing
Issues, PRs, and discussions all welcome.
- Bug reports → open an issue with a minimum reproducible example and the platform you saw it on (Impeller vs Skia matters for shader behavior).
- Feature requests → an issue with the
enhancementlabel is fine. - Pull requests — fork, branch, keep commits small and focused, and make sure
flutter analyzefrom both the repo root andexample/stays clean. - Editing a
.fragfile → runflutter clean && flutter pub getinexample/before re-launching. Flutter caches compiled shader assets and hot reload won't pick up GLSL changes on its own.
Maintainer: John Patrick Prieto
License
MIT © 2026 John Patrick Prieto
Built by John Patrick Prieto · GitHub
Libraries
- shader_transitions
- GPU-accelerated, shader-based screen transitions for Flutter.








