Close05 / 07

05 / 07

Pixel Reveal

At Build in Amsterdam · 2026 · Concept and development

A product image mid-reveal: coarse mosaic blocks refining outward from the focal point

Every image on the site arrived as a coarse mosaic and resolved to sharp, refining outward from a point computed from the picture itself.

A branding device rather than a loading indicator. It played on a schedule, not on network conditions, so it looked the same on a fast connection as a slow one — a moment the site performed rather than a symptom of latency it was apologising for.

The constraint set

The site takes millions of visitors, so the interesting part was never the effect. It was the list of things the effect was not allowed to cost.

No additional CDN requests, at all. next/image intact — its optimisation pipeline, srcset, alt text and LCP behaviour untouched. No fidelity loss against the prototype. And it had to degrade to nothing: on a slow device, with reduced motion, in high contrast, or without JavaScript, the visitor sees the image plainly and never knows a feature is missing.

Most of the obvious approaches die on the first constraint. CSS has no downsample primitive, so a full-resolution source renders sharp regardless. SVG filters interpolate and can’t do per-region refinement levels. Loading four or five low-resolution CDN variants as stacked layers is four or five extra requests per image. WebGL refuses cross-origin textures without CORS outright. The CSS Paint API can’t read external image data and Safari doesn’t ship it.

The faithful port of the prototype — crossOrigin plus getImageData — works, and was the most expensive option on the list. Adding the attribute changes the cache key for every returning visitor, which means a one-time re-download wave across the entire user base.

The load-bearing fact

Drawing a cross-origin image into a 2D canvas without CORS is legal. It taints the canvas, and a tainted canvas forbids reading pixels back — not displaying them.

The prototype read pixels. Production didn’t need to. Every cell decision is procedural maths and compositing is clip plus draw, so nothing ever reads the surface. One property of the platform, correctly identified, satisfies three constraints at once: no crossOrigin attribute and therefore no cache-key change and no request delta; next/image untouched; identical output.

The architecture follows from it. The real image renders normally and is never hidden. A canvas sits over it at mosaic resolution — around 128×160, some 80 KB — upscaled by CSS with image-rendering: pixelated. On the last tick every cell turns transparent and the overlay unmounts. Removal needs no transition because the real image was underneath the whole time.

The mechanics

The image is sampled once into a pyramid of four or five tiny offscreen canvases, 8 to 128 pixels wide, drawn from the element the browser has already downloaded. Block counts follow coarse × 2ⁱ, and the grid snaps so every level divides the canvas exactly — non-integer ratios smear blocks into the wrong rows on wide images.

Each cell measures its distance to a normalised focal point, scaled by a falloff, and adds a deterministic seeded noise term sampled at i>>2 and i>>3 so refinement advances in chunky patches rather than single-cell dust. The cell rule is level = floor(g − dist·falloff + noise), clamped at zero; a cell at or above the level count goes transparent and the sharp image shows through. Upscaling is nearest neighbour only — no smoothing, no easing, no fades. Every block colour samples the loaded image at play time, so nothing is precomputed and nothing ships.

Pace, seed and coarseness randomised per play. No two plays were identical.

Where the focal point comes from

Four sources, first hit wins. An explicit point from the CMS, when art direction wants one. A contrast focal point computed on the server, which walks resolved content at the resolver choke points and attaches a saliency point to every raster image in place — a 32-pixel thumbnail, greyscaled, run through a gradient-energy centroid. On same-origin images, the same algorithm run client-side against the pyramid the engine has already built, costing nothing. And a deterministic per-source hash into a central band, so rendering never blocks on anything.

Client and server share one implementation and agree on real images. The server path is memoised across requests in a 500-entry LRU with refresh-on-hit, because the data cache dedupes the fetch but not the decode. The thumbnail fetch carries a 1.5-second timeout and only successful analyses are cached, so a slow CDN degrades to the fallback instead of blocking a render or poisoning the cache.

Making it cheap

The hot paths are the build, once per play, and eleven stepped ticks across 16,000 to 26,000 cells.

Noise and scaled distance merge into a single Float32Array, halving frame-loop reads — valid because negatives clamp to zero anyway. The build tracks its minimum so playback ends on the last visible change instead of running trailing no-op frames. Math.sqrt replaces Math.hypot, which pays for overflow exactness across twenty thousand cells that this doesn’t need. The two noise octaves recompute only on 4- and 8-cell boundaries, cutting roughly 40,000 hash calls per build to 7,000. Cold canvas init runs in requestIdleCallback, off every measured path. A single module-level IntersectionObserver with a WeakMap of callbacks replaces one observer per image — 24 down to 1 on a listing page.

Measured after that pass: build 1.6 to 3.2 ms, ticks 0.1 to 0.3 ms median with 2.2 ms worst observed. Around 300 KB of buffers per playing image, held for about a second. A full grid animating at once stays near 5 MB, and steady state holds no canvases at all.

Guaranteeing it does no harm

Two layers. Heuristics decide whether a play starts: low core count, low reported memory, save-data, or a 2G connection prevent it. Then a self-timing circuit breaker — two consecutive ticks over 24 ms abort the play instantly and count against the session; two strikes and it stops trying. The first play is exempt because it pays one-time JIT warmup.

Heuristics decide whether to start. Measurement guarantees harmlessness when heuristics lie.

The real <img> and its alt text stay in the tree throughout, so screen readers never wait. Reduced motion and forced colours are guarded in CSS on the cover itself rather than only in the JavaScript gate, so those users see the image at first paint without waiting for hydration. An image that fails to load settles the overlay and the slot degrades as though the feature had never been there.

The honest cost

Every first view of every image read as pixelated for around eight tenths of a second before it went sharp. That was the feature, not a side effect, and it was signed off knowing exactly that.

The effect covered the LCP element without delaying it. A hero spike proved it: with the mosaic drawn over a real cross-origin hero and no CORS attribute, the occluded image was still the LCP element in every run, and the browser’s own paint-timing API emitted the entry while the cover was up.