18 October 2026 · 5 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. Since I first wrote it the page has grown one step in each direction, so the numbers below are the current ones.
- 1Target · the box the next depth fits into
- 2Incoming content · measured once per layout
- 3Hand-over · surroundings fade first, the target survives
- 4Scale readout · the product of the measured ratios
- 5Flat fallback · the same document without the zoom
Seven layers, one scroll
The document itself is seven empty blocks, each one viewport tall. That is all the browser scrolls. The content lives in seven full-screen layers stacked in a fixed element on top. The scroll position becomes a number, the depth, from −1 at the map to 5 at the machine, with 0 at the surface where the page opens, 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 2 000× at the machine, and a map scale like 1:100 one step above the surface. 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.
One step up, one step down
The first version ended at the code. Two things bothered me about that. A piece of code is not the bottom of anything, it runs on something; and a name on an empty page is not the top of anything either, it is somewhere. So the page now goes one step further each way. Scroll up from the name and it shrinks into a dot on a map of southern Vättern, drawn from OpenStreetMap geometry at seven pixels to the kilometre, with Jönköping at the tip of the lake. Scroll down past the code and you reach the machine it runs on: a hood-type dishwasher, a phone on a windowsill, a laptop with five processes pretending to be five machines. Below that there are only people, and the page says so and stops.
The code itself runs while you look at it. One line lights at a time, with a word beside it saying what state the machine is in, and for the dishwasher the steps take as long as they do in a real programme, with a clock. The page also remembers where you left it, opens there next time, and forgets as soon as you go back to the surface. Press P and every depth is laid out as a numbered sheet with a title block, which is also what the page prints as. There is a tick at each hand-over if you turn it on, and in the evening the paper warms a shade.
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 six 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.