3 min left
3 min read

A page transition that breathes

Michael Hospedales, software engineer in Miami, FL
Michael Hospedales

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.push itself 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.