Transitions
A canvas page transition — twelve coverage styles crossed with six wave orderings.
A full-viewport transition: a field of cells grows until it seals the screen, the route is swapped while the view is covered, then the wave clears off the new page.
Click any tile to play that style full-screen over this page. Pick a wave first to change the order cells reach coverage — the two axes are independent, so all 72 combinations are reachable from here.
Thumbnails are real output — each is one frame of that style caught mid-sweep, drawn by the same renderer the transition uses. Playback runs at the 300ms sweep default.
The full bench, with sweep, grow, jitter, easing and exit direction exposed as live controls, is at /lab/transitions.
Installation
npx shadcn@latest add https://ui.whoisroktim.lol/r/transitions.jsonUsage
The provider has to live in a layout, not a page. Layouts persist across navigation; a page unmounts as soon as you leave it, taking the overlay with it and losing the reveal half of the animation.
import { TransitionProvider, TransitionStage } from "@/components/app/transitions";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<TransitionProvider>
<TransitionStage>{children}</TransitionStage>
{/* Fixed chrome goes OUTSIDE the stage — see below. */}
</TransitionProvider>
);
}Then navigate with TransitionLink instead of Link:
import { TransitionLink } from "@/components/app/transitions";
<TransitionLink href="/vendors" className="...">
Go to vendors
</TransitionLink>;For anything that is not a link — a form submit, a redirect after an action —
take navigate off the hook:
const { navigate, busy } = usePageTransition();
await save();
navigate("/done");Two rules that are not style choices
Keep fixed chrome outside TransitionStage. The stage applies a transform so
the page recedes as the curtain closes, and a transform makes an element the
containing block for position: fixed descendants. A header or toolbar inside
it will drift with the page instead of staying put.
The provider belongs to the layout. Covered above, but it is the single most common way to end up with a transition that covers and never uncovers.
Styles and waves
Two independent axes. Style decides how coverage is painted; wave decides the order cells reach it. Every style reads the same coverage field, so the timing contract is identical across all of them — 72 combinations.
| Style | Reads as |
|---|---|
dots | circles merging |
halftone | staggered print screen |
rings | sonar annuli |
bayer | ordered 1-bit dither |
noise | grainy film dissolve |
pixels | mosaic blocks |
diamond | tiles on point |
hex | honeycomb |
blinds | venetian slats |
stripes | vertical slice |
glyph | ASCII density ramp |
iris | one expanding circle |
Waves: diagonal, left, up, center, spiral, corners.
bayer uses the same 8×8 matrix as the dither textures in scripts/, so a
page that already uses that artwork transitions in the same pattern.
Defaults, and why they are where they are
{ style: "dots", cell: 26, sweep: 300, dot: 190,
direction: "diagonal", curve: "smooth", jitter: 0.07,
back: "retract", ink: "brand" }The cover is the part a user actually waits through — it runs ~510ms, with the whole round trip at ~940ms. The reverse is deliberately quicker (0.65×): reverse motion should take less time than the original, because the action has already been understood.
curve redistributes the stagger rather than spreading it evenly; a linear
stagger gives a ruler-straight wavefront at constant speed, which is the
clearest tell that something is scripted. jitter ragged-edges the wavefront.
back: "retract" inverts the delay order on the way out so the curtain undoes
itself — with continue the wave carries on and exits the far edge, which
suits a straight sweep and looks wrong on a centre one.
cell is per style: a dither grid wants ~9px and merging circles ~26px.
Performance
The renderer is canvas, and it has to be. One <span> per cell with its own
CSS transition is 2,800 nodes and 2,800 concurrent transitions at 1080p, and
over 23,000 nodes at 1440p. Canvas collapses that to a single element and one
batched fill per frame, and React never re-renders during the animation.
prefers-reduced-motion skips the curtain and navigates straight through.