Detail 08 · Transitions
A state machine for transitions
Five phases, one string: how every page change here is choreographed, and why loose booleans would break it. 7 minutes, 2 demos.
- idle
- covering
- covered
- drawing
- revealing
01 · The idea
A page change is a little film
On most sites a link swaps one page for the next. Here it plays out in order: the page you’re on sinks away, the screen stays empty while the next page loads, a pencil sketch of that page draws itself in, and the real page builds inside the sketch.
Each step can only start once the one before it has finished. Some take a fixed time; one, loading, takes as long as the network does. That’s choreography, and choreography needs one place that always knows what’s happening right now.
So the whole thing runs on a single value, phase, which is always exactly one of five words: idle, covering, covered, drawing or revealing.
02 · Watch it
One click, five phases
Below is a miniature of the site, running the same five phases at the real timings. Click the link inside the little page and follow the strip underneath; slow it down to watch each phase on its own.
- idle
- covering
- covered
- drawing
- revealing
Waiting for a click. Nothing is animating.
The address bar gives the trick away. The route changes in the middle, while nothing is on screen, and that unseen moment is what the cover is for: by the time anything appears, the new page is already there to be measured and sketched.
03 · How it’s built
Five phases, one string
Each phase owns one job, and each knows how it ends: a timer runs out, the new page arrives, or an animation calls back to say it’s finished.
1.idle
Nothing is animating. A click on any link calls navigate(), the only way in.
setPhase("covering")2.covering
The page you're on sinks away: it scales down and blurs, then fades. It ends on a timer.
setTimeout(handleCovered, …)3.covered
Unseen: jump to the top, change the route, then wait for the new page to arrive.
router.push(href)4.drawing
Measure the new page's marked blocks and sketch them in pencil. The sketch says when it's done.
onDrawn → revealing5.revealing
Each block blurs in where its box was drawn. When the last sketch has faded, it's over.
onGone → finish()
Two branches keep it honest. A page with no wireframe marked skips drawing and goes straight from covered to revealing. And the résumé island comes in its own way: instead of the page sinking, its thumbnail flies to where the sheet sits on the next page, with the sketch riding on it. Same machine, a different way of covering.
04 · Why not booleans
Impossible states, made impossible
The obvious way to build this is a handful of flags: isLeaving, isLoading, isDrawing, isRevealing. Four booleans allow sixteen combinations, and only five of them make sense. Nothing stops the other eleven from happening.
Double-click the link below with booleans. Each click starts its own transition, they run on top of each other, the page flips twice, and you land back where you started. Switch back to one phase and the second click is simply ignored.
- idle
- covering
- covered
- drawing
- revealing
Extra clicks ignored: 0
One line does the guarding: navigate() returns early unless the phase is idle. A phase can’t be two things at once, so there’s no combination left to get wrong.
05 · The code
Small pieces, each with one job
navigate() is the only way in. Clicking the page you’re already on just glides back to the top, visitors who prefer reduced motion get a plain page change, and anything that isn’t idle is turned away.
const navigate = useCallback(
(href, { grow = null, carry = null } = {}) => {
const path = new URL(href, window.location.href).pathname;
// Already here: just glide back to the top
if (path === pathname) {
if (lenis) lenis.scrollTo(0);
else window.scrollTo({ top: 0, behavior: "smooth" });
return false;
}
if (phase !== "idle") return false;
if (reduceMotion) {
router.push(href);
return false;
}
// Within a shell only the content leaves: a frozen copy of it, so the new
// content can load in its place straight away
const content = !grow && !carry && sharesShell(pathname, path) ? document.querySelector("[data-transition-content]") : null;
setTarget({
href,
path,
grow,
carry,
contentOnly: Boolean(content),
snapshot: content ? freeze(content) : null,
// The page sinks toward the middle of what's on screen, not of the whole page
leaveOrigin: `50% ${window.scrollY + window.innerHeight / 2}px`,
});
setLayout(null);
setGrowSketched(false);
setCarried(carry && { ...carry, to: null, id: performance.now() });
setPhase("covering");
return true;
},
[pathname, phase, reduceMotion, router, lenis]Each phase’s ending lives in an effect keyed on the phase. The effect starts the timer and its cleanup clears it, so if the phase moves on for any other reason, a stale timer can never fire late.
// The page sinking away covers the switch (a growing element calls handleCovered
// itself). A frozen copy needs no waiting: it sinks on its own over the new content
useEffect(() => {
if (phase !== "covering" || target?.grow) return;
const timer = setTimeout(handleCovered, target?.snapshot ? 0 : LEAVE_DURATION * 1000);
return () => clearTimeout(timer);
}, [phase, target, handleCovered]);Covered is the one phase that waits on the outside world. It holds until the new route has really arrived, gives the layout a moment to settle, then measures the page for its sketch. If the page never comes, it reveals anyway after ten seconds.
// New page is in: sketch its wireframe if it has one, otherwise reveal it
useEffect(() => {
if (phase !== "covered") return;
if (pathname !== target?.path) {
const timer = setTimeout(() => setPhase("revealing"), MAX_WAIT);
return () => clearTimeout(timer);
}
let frame;
const timer = setTimeout(() => {
// A carried element sets off for its place on the new page
if (target.carry) {
const rect = target.carry.find()?.getBoundingClientRect();
setCarried((carry) => carry && { ...carry, to: rect ? { left: rect.left, top: rect.top, width: rect.width, height: rect.height } : null });
}
// A growing element brings its own sketch: wait for that to finish
if (target.grow) {
setPhase("drawing");
return;
}
if (!hasPageWireframe(target.path)) {
setPhase("revealing");
return;
}
frame = requestAnimationFrame(() => {
const measured = measurePageWireframe(target.path, { contentOnly: target.contentOnly });
if (measured) {
setLayout(measured);
setPhase("drawing");
} else {
setPhase("revealing");
}
});
}, SETTLE);
return () => {
clearTimeout(timer);
cancelAnimationFrame(frame);
};
}, [phase, pathname, target]);Everything else is worked out from the phase, never stored beside it: whether the page is covered, and how the page should look. Moving between two details is the same machine with a smaller stage and a quicker pace: only the content changes, while the list of details beside it stays put. Covering lasts a moment there, because a frozen copy of the old content does the leaving (the next detail shows why).
const isCovered = COVERED_PHASES.includes(phase);
const grow = target?.grow;
const leaving = phase === "covering" ? "leaving" : "hidden";
// The page's state (see PageStage); a growing element covers it instead, and
// within a shell only the content goes (see PageContent): hidden at once, its
// frozen copy does the leaving
const stage = grow || !isCovered || target?.contentOnly ? "shown" : leaving;
const contentStage = target?.contentOnly && isCovered ? "hidden" : "shown";06 · Decisions
Why it’s built this way
One string, not four flags
Five legal states, and no way to be in two at once. Adding a phase means adding a word, not checking every combination of flags.
Timers live in effects
Each phase starts its timer in an effect keyed on the phase, and the cleanup clears it. A phase that ends early can’t be ended twice.
Pages don't know
A page only marks its parts for the sketch and the reveal. It never imports the transition: its content reads whether the page is covered and waits.
Always a way out
If the new page never arrives, it reveals anyway after ten seconds. A stuck cover is worse than a missing sketch.
One gap worth naming: the browser’s back and forward buttons skip all of this. The browser swaps the page itself, instantly, so there’s nothing to sink away. Playing the sketch and reveal on arrival there too is next on my list.
07 · Takeaways
What to keep
- Choreography needs one source of truth: a single phase, not a set of flags.
- Give each phase one way to end (a timer, an event or a callback) and clean it up when the phase changes.
- Derive everything else from the phase, and guard the way in: only idle can start a transition.
Watch this page sink away, the home page’s sketch draw in, and the page build inside it.