Lab — concept build
Anchor: grey-card
Light does the cutting.
01 — The frames






Fushimi Inari, Kyoto
Daylight, filtered through vermilion
Daylight arrives here already coloured. It passes between the uprights and comes out the far side warm, so the shade inside the tunnel is orange rather than grey.
Taipei
Flat haze, no shadow
Low cloud takes the direction out of the light. Nothing casts a shadow, the city loses its edges behind the tower, and the tower keeps its own only because it is closer.
Tokyo
Fluorescent tube, overhead
The only interior in the set, and the only light in it that a person switched on. It falls straight down, so every face is lit from above and nothing in the carriage is lit twice.
Waterfront, Taiwan
Blue hour, from behind
The sun has gone but the sky has not caught up. Two figures read as silhouette against it, and the only warm light left in the frame is on the far shore.
The Strip, Las Vegas
Neon, from every direction at once
A street lit entirely by signs. There is no key light and no fill, just a hundred sources at a hundred colour temperatures, none of them agreeing.
Mae Haad, Ko Pha Ngan
The last of the sun, along the water
The end of the sequence and the end of the day. The sun is low enough to be in the frame rather than on it, and everything between it and the lens is silhouette.
The index
Six links, six ids, one aperture. With a script running, a click is intercepted and the change is a WebGL dissolve; without one, the link is an ordinary fragment link and :target in the stylesheet raises that frame in the stack. Both paths describe the same state, and only one of them is ever driving.
Not client work, and not a rebuild of anything. The photographs are Kush's own, reused from the set already optimised for /lab/photography-rebuild, and the six captions describe only what is visible in them — no date, no camera, no exposure. This piece is a free reimagining and carries no before/after comparison: there is no site it replaces, so any figure set against a predecessor would be a comparison with something that was never measured. The one number it does publish about itself, the route's gzipped JavaScript, is read out of the built output by the same function that enforces the budget.
02 — A crossfade destroys both photographs to get between them
Six photographs, ordered by the light in them. Moving between two of them is a displacement transition written straight against the WebGL API.
For the length of a crossfade, the visitor is looking at neither picture. Two images at half opacity produce a flat average — a colour that exists in neither of them, at a contrast lower than either, with both sets of edges present and both half-erased. It is the one transition in wide use that actively damages the thing it is transitioning between, and on a photography site the thing it is transitioning between is the entire product.
A displacement transition does not fade the whole frame. A noise field is evaluated per pixel; the field decides when that pixel hands over, and the blend is confined to a ragged front that sweeps across the picture. Ahead of the front the outgoing photograph is at full strength, behind it the incoming one is, and only the band the front is currently crossing carries both — the mixing is a moving seam rather than a state the whole picture passes through. While it passes it drags both frames a little along its direction of travel, which is where the effect stops being a wipe and starts reading as light moving across the image.
Three things follow from that, and they are the whole shader:
The noise is generated, not downloaded. Displacement transitions are usually driven by a map somebody exports as a PNG — another request, another decode, another asset to keep. Four octaves of value noise is two dozen lines of GLSL and weighs nothing, and because it is a function rather than a file the front can be re-seeded per pair of frames, so the same two photographs do not melt along an identical edge every time.
The pointer steers it. The direction the front travels is a uniform, sampled from where the pointer last was and read at the moment a transition begins rather than continuously — read continuously it would swim. On a touch screen the swipe supplies the direction instead, which is the same gesture that changed the frame.
It stops. Displacement, the light along the front and the grain are all multiplied by an envelope that is zero at both ends, so a settled frame is the photograph and nothing else — not the photograph plus a permanent shimmer. An effect that never switches off is an effect competing with its subject.
The reflex when a page needs a shader is to reach for a 3D library. The reflex is wrong by an order of magnitude here: what this needs is one triangle covering the aperture, two samplers and fewer than seventy lines of GLSL across two shaders, while a general 3D engine also brings a scene graph, cameras, materials, geometry loaders and a renderer abstraction — none of which appear anywhere in the effect, and all of which would be downloaded before the first photograph.
03 — Three ways a WebGL page arrives broken
A canvas is not a progressive enhancement. It is an empty box that used to be a photograph, and it is empty in three situations a developer's machine never reproduces: a GPU the browser has blocklisted, a visitor whose system asks for reduced motion, and JavaScript that does not run. Each of the three has a different correct answer, and none of them is "a black rectangle where the picture was".
| No WebGL | The context is never created, or a shader fails to compile — both are checked rather than assumed. The canvas is removed if it was made at all, and the stack of <img> elements the page already rendered stays, with a CSS crossfade between frames. |
|---|---|
| Reduced motion | No context, no shader, no texture upload — the work is never done rather than done and discarded. Frames cut, with no transition at all, which is the correct finished state rather than a frozen one. |
| No JavaScript | The canvas is never in the markup, so there is nothing to fail. The index is six fragment links and :target raises the matching frame; :has() moves the caption with it. |
The first of the three is the one that usually ships broken, because it cannot be seen on the machine it was built on. It is exercised deliberately in the end-to-end suite by stubbinggetContext to return null, and the other two by Playwright's own reduced-motion and JavaScript-disabled modes. The canvas here also only ever becomes visible after it has drawn a frame — a context that exists and renders nothing looks identical to one that works, so the reveal hangs on the return value of the first draw.
04 — 6,995 bytes of JavaScript
That figure is the whole route: the effect, the site's shared interaction runtime, and everything else this page executes, gzipped. The ceiling every route on this site is built against is 30,720 bytes, so the piece spends 22.8% of it.
It is not typed from memory. It is produced by routeJsBytes() in scripts/check-budgets.mjs — the same function that enforces the ceiling, following the module graph rather than stopping at the <script src> attribute — and tests/e2e/six-frames.spec.ts reads the built output back and fails if the number printed above is not the number in dist/. A published figure with a test behind it is a measurement; one without it is a memory.
Nothing compiles until you reach for the viewer. The first version built its context on the load event, which already keeps the work behind the largest paint. Lighthouse still recorded a 942 ms long task inside the module and 820 ms of total blocking time, taking performance to 80: with no GPU the browser rasterises in software, where compiling a shader and uploading a 1536-pixel photograph is real main-thread work. That is not a headless artifact — it is what a cheap phone does. Moving the setup behind the first sign of intent (a pointer entering the viewer, focus arriving in it, a finger going down on it) took blocking time to zero, and a visitor who scrolls past now pays for no GPU work at all.
| Gzipped JavaScript, this route | 6,995 bytes | of 30,720 |
|---|---|---|
| Mobile — performance | 100 / 100 / 100 | |
| Mobile — LCP | 1.5 s / 1.2 s / 1.3 s | |
| Mobile — CLS | 0, three runs | |
| Mobile — total blocking time | 0 ms, three runs | |
| Desktop — performance | 100 / 100 / 100 | |
| Desktop — LCP | 0.4 s, three runs | |
| Desktop — CLS | 0, three runs | |
| Accessibility, best practices, SEO | 100 on both form factors | |
Deployed, and that word is doing work. These runs were taken against this page as published, over Cloudflare's CDN — real DNS, a real TLS handshake, real distance. An earlier version of this table printed 1.8 s and called it local, with a caveat that nothing here had been measured over the internet. That caveat was honest and it is now obsolete, so the numbers were replaced rather than annotated.The local figure was the harness, not the page. /lab/print-shop and /lab/seal-shop both measured 1.8 s on that same local harness and measure 1.4 s and 1.6 s deployed — which is the argument for never reporting a localhost number as though it described the internet.
05 — Notes
The photograph is the payload. Everything else on a photography site is packaging, and most of the packaging is spent on the moment between two photographs — the moment a site has to get you from one to the next.
Almost every site does that moment the same way: it fades. One picture goes to zero opacity while another comes up from zero, both at once, and for a third of a second the visitor is looking at neither — at a flat average of two images, which is a colour that appears in neither of them. It is cheap, it is universally understood, and it is the only transition in wide use that actively destroys the thing it is transitioning between.
The alternative is to move light instead of opacity. A displacement transition samples both frames through a noise field: where the field is low the outgoing frame has already gone, where it is high it is still there, and between those two regions is a ragged front that sweeps across the picture. The blend is confined to that front. Ahead of it the outgoing photograph is at full strength and behind it the incoming one is — no part of the frame is ever at half opacity for the whole length of the transition — and while the front passes it drags both a little along its direction of travel, so the cut reads as light moving across the image rather than as one image dying into another.
That effect needs the GPU, and the reflex is to reach for a 3D library to get at it. The reflex is wrong here by an order of magnitude. What this actually requires is one quad covering the aperture, two textures, one noise function and fewer than seventy lines of GLSL — and a library that also does scene graphs, cameras, lighting rigs and geometry loaders is several times the weight of the entire page it would be running on. This one is written by hand, against the WebGL API directly, and the whole route’s JavaScript is printed on the page in bytes.
The harder half is not the shader. It is that a WebGL page has three ways to
arrive broken and all three are invisible from a developer’s machine: a GPU
the browser has blocklisted, a visitor who has asked their operating system
for reduced motion, and JavaScript that never runs. A canvas is not a
progressive enhancement by default — it is an empty box that used to be a
photograph. So the aperture on this page is a stack of ordinary img
elements first and a canvas second, the canvas is only revealed once it has
actually drawn a frame, and the index below it is a list of real links whose
:target selector moves the stack with no script at all.
Measured build time: 1 hr 10 min.