18 October 2026 · 4 min read
One page that zooms
How the home page of this site works, why it has no menu, and what I learned about crossfades the hard way.
Most personal sites are the same page. A header with four links, a hero, a grid of cards, a footer. I have built that page more than once, and I wanted this one to feel like leaving the rest of the internet for a minute. So the home page here is one object. You arrive looking at a name. Scrolling does not move you down, it moves you closer: into a sentence, into one phrase of that sentence, into the project behind the phrase, and finally into a small piece of its code. Then you scroll back up and leave.
This note is about how that works, because the mechanism is small and I think it is worth sharing.
Five layers, one scroll
The document itself is five empty blocks, each one viewport tall. That is all the browser scrolls. The content lives in five full-screen layers stacked in a fixed element on top. The scroll position becomes a number, the depth, from 0 at the top to 4 at the bottom, and a scroll handler turns that number into transforms.
Between any two depths, exactly two layers are visible. The outgoing layer has a target, the thing you are about to enter: at depth 0 it is the box around the name, at depth 2 it is the little label above a phrase. The incoming layer has a content box, the block that sits in the middle of the screen at its natural size. Both are measured once per layout. Everything else follows from one ratio:
const s = target.width / incoming.width; // how much of the incoming layer fits in the target
const k = 1 / s;
// p runs from 0 to 1 between the two depths
out.style.transform = `translate(${(cx - tx) * p}px, ${(cy - ty) * p}px) scale(${k ** p})`;
inc.style.transform = `translate(${(tx - cx) * (1 - p)}px, ${(ty - cy) * (1 - p)}px) scale(${s ** (1 - p)})`;
The outgoing layer grows around its target and slides that target to the centre. The incoming layer starts shrunk inside the target and grows to full size while sliding the same way. At every value of p the target and the incoming content occupy the same rectangle, so the hand-over is seamless. The number in the top-left corner is the product of all the ratios so far, which is why it says something like 330× at the bottom. It is not decorative, it is measured.
What I got wrong first
My first version crossfaded the two layers over the middle half of each transition. It looked like a mess, because the rest of the outgoing layer, the sentence around the phrase or the big name above the sentence, was growing huge behind the incoming content. The fix was obvious once I saw it: the surroundings have to fall away first. Everything in the outgoing layer that is not on the path to the target fades in the first third of the transition. Only the target survives into the crossfade, and the crossfade itself got shorter. The Eames film does the same thing. You never see the picnic blanket once you are among the galaxies.
The parts nobody sees
The layers are ordinary HTML with real headings, so search engines and screen readers get a plain document. If your system asks for reduced motion, the script never runs and the same markup lays itself out flat. Arrow keys step between depths, Escape takes you back to the surface, and the layers that are not in front are marked inert so the Tab key cannot wander into something invisible. There is also a flat version at /all for anyone in a hurry.
The whole thing is about two hundred lines of TypeScript, no animation library, no framework on the client. Astro renders the pages, Cloudflare serves them. If you are curious about a detail, the address at the bottom of the zoom reaches a person.