A page transition that breathes
Drafted by AI · reviewed & edited by Michael
The first version of this site's page transition was a full-screen sumi-ink wipe. Dark pigment swept across the viewport on every navigation, and it looked impressive for about three clicks. Then it just felt heavy — a curtain falling every time you moved. It went through four rounds of "make it subtler" before landing where it is now: a roughly 300ms breath where the page exhales through warm paper and the next page inhales into place.
This post is about what that iteration taught me — about shader math, progressive enhancement, and why the most important part of a decorative effect is what happens when it fails.
Bokashi, not curtains
The final effect borrows from bokashi, the gradation technique in Japanese woodblock printing where ink fades smoothly across the block instead of stopping at a hard edge. The overlay is a single fullscreen triangle drawn with raw WebGL — no framework scene, no camera, just one fragment shader that computes a soft coverage sweep:
float sweep = (1.0 - vUv.y) * 0.3 + vUv.x * 0.05;
float c = uProgress * 1.15 - 0.1;
float alpha = smoothstep(0.0, 0.45, c - sweep);
Two details in that snippet carry the whole feel. The sweep biases coverage
diagonally, so the veil arrives like light moving across paper rather than a
wall sliding in. And the coverage variable c runs from -0.1 to 1.05 —
deliberately past both ends of the progress range. That over-run is the
mathematical guarantee that every pixel is fully transparent at rest and
fully covered at the peak. The earlier version clipped the range at exactly
[0, 1], and the corners of the screen held a faint 17% tint when the
overlay unmounted — a one-frame "pop" you couldn't name but absolutely felt.
Animation bugs like that rarely show up in code review. They show up in the
math, when you check the boundary values by hand.
The color is not a flat wash either. The veil grades from a rose whisper through lavender into warm washi white, with a little fractal noise for paper grain — the same palette the rest of the site draws from, so the transition reads as the page itself breathing rather than something laid on top of it.
The effect is optional. Navigation is not.
Here is the rule that shaped most of the code: the transition must never be load-bearing. Navigation is the product; the veil is decoration. So every failure path falls back to plain navigation:
- If WebGL is unavailable, a timer drives the same cover/reveal timeline without drawing anything.
- If the shader fails to compile or link — driver quirks are real — the
code checks
LINK_STATUS, frees the GL resources, and falls back to that same timer path. - A React error boundary wraps the overlay, so a crash in the effect degrades to instant navigation instead of a broken page.
- Per-phase watchdogs cap every stage. If "covering" takes longer than 1.5s,
the watchdog fires the pending
router.pushitself and gets out of the way. The animation can be late; the click cannot be lost.
The link interceptor is equally paranoid in the other direction — it only engages for real page changes. Same-path clicks, hash jumps, and file links like the RSS feed bypass the veil entirely and navigate natively.
And if you have reduced motion enabled, none of this exists: the site
respects prefers-reduced-motion globally, and page changes are immediate.
Testing motion in a suite that bans it
The end-to-end suite here forces reduced motion everywhere — animations are flaky-test fuel, so the default context simply turns them off. Which raises a question: how do you test the one feature that is motion?
The answer was a single dedicated spec that opts back in
(reducedMotion: 'no-preference') and a data-transition-veil attribute on
the overlay that exposes its phase to the test. The spec asserts three
things: the veil covers and unmounts around a navigation, rapid clicks
mid-transition never strand the page, and the RSS link never triggers the
veil at all. Notice what it does not assert: anything about how the
transition looks. The tests guard the contract — navigation always
completes — and leave the aesthetics to human eyes.
What I'd tell past me
Start subtle. The ink wipe was the effect I wanted to build; the breath is the effect the site needed. Every iteration removed something — color, darkness, duration — and the result is a transition most visitors will never consciously notice. That is the point. A good page transition is like a good cut in film: you feel the rhythm, not the technique.
The full implementation lives in the site's repo — InkWipe.tsx for the
shader and TransitionProvider.tsx for the fail-safes — and this site's
case study covers the broader redesign it belongs
to.