Shattering the DOM
How the page transition on this site snapshots a live screen, tessellates it into glass shards and blows it toward the camera, without dropping a frame or losing the carousel underneath.
Every navigation on this site breaks the outgoing screen into glass and throws it at you. It looks like a gimmick, and it mostly is, but making it work turned out to be a decent tour of the awkward seam between the DOM and WebGL, so here is how it actually runs.
The problem with animating a page
A vertex shader can only move geometry it has been given. A web page is not geometry: it is text, images, borders, a couple of live canvases and whatever the browser decided to do with them. To shatter a screen you first have to turn it into something a GPU can hold: a single texture.
That is the whole trick. Everything else is bookkeeping.
Step one: photograph the screen
html-to-image serialises a DOM subtree into an SVG foreignObject and paints it to a canvas. It works well, with two caveats that both bite here.
Live WebGL canvases come out blank. The carousel on the Work screen is a <canvas> with its own context; serialising it produces nothing. Cross-origin images are worse: they taint the canvas and the whole snapshot throws. So both are filtered out before serialisation:
function keepInSnapshot(node) {
if (node.tagName === 'CANVAS') return false;
if (node.tagName === 'IMG') {
const src = node.src;
if (src && !src.startsWith('data:') && !src.startsWith(location.origin)) return false;
}
return true;
} Which leaves holes. They get filled back in afterwards.
Fonts get re-fetched on every capture. html-to-image inlines every @font-face it finds, as base64, each time you call it. With four self-hosted families that is a lot of work to repeat on every single navigation. The fix is to build that CSS once, cache it, and hand it over as fontEmbedCSS with skipFonts: true.
Step two: rebuild what the camera missed
The snapshot is transparent wherever the page background was, and empty where the carousel is. Before it becomes a texture it gets composited onto what was sitting underneath, in the same order the browser paints:
- the page ground, taken from the frame's computed
background-colorrather than the custom property behind it, so it is always a resolvedrgb()and it follows whatever the browser actually painted; - every live
<canvas>in the captured subtree, copied across at its measured position; - then the snapshot on top.
The ambient layers, the starfield and the 60 px HUD grid, are deliberately not on that list, and the reason is worth the detour. They sit outside the captured screen, so the first version simply redrew the grid into the texture. That looks correct for exactly one frame, and then it flies away with the shards, which reads as the grid blinking out of existence for a second and a half. A frozen copy was never going to hold up anyway: the shards magnify by roughly 15 per cent within a few frames of letting go, so the redrawn 1 px lines smear and drift out of step with the real ones underneath. Those layers belong to the frame, not to the screen. Now they ride above the shards for the duration of the transition and stay exactly where they are.
Copying a live WebGL canvas only works if it still holds its pixels, which is not the default. A renderer clears its drawing buffer after compositing unless you ask it not to. The carousel is created with preserveDrawingBuffer: true for exactly this one reason.
Step three: tessellate
The shards come from a jittered grid: nine columns by seven rows on a desktop, six by five on phones. Interior grid nodes are nudged by up to half a cell so no two shards match; the outermost nodes are left alone so the shattered plane still lines up with the viewport edge.
Each cell becomes a slab, not a flat quad: two textured faces separated by a thickness, plus four untextured side walls. Those walls are what make it read as glass rather than paper, because they catch a specular highlight the faces never could.
All of it, every face and every wall of every shard, goes into one BufferGeometry. One geometry, one material, one draw call. The per-shard behaviour that would normally live in a transform matrix is baked into vertex attributes instead:
aOrigin // the shard's centre, so it can rotate about itself
aDir // outward direction from the impact point
aRadial // how far it travels sideways
aZSpeed // how fast it comes at the camera
aDelay // when it lets go
aSpin // per-axis tumble
aIsEdge // face or side wall Vertex positions are stored relative to the shard centre, which is what makes self-rotation possible in a shared geometry: the shader rotates the local offset, then adds the animated centre back on.
Step four: the shader
Each shard reads its own delay and remaps global progress into a local one, so the shards nearest the impact point leave first:
float local = clamp((uProgress - aDelay) / (1.0 - aDelay), 0.0, 1.0);
float fwd = easeOut(min(1.0, local / 0.25)) * 0.35 + local * 0.85; That second line is two motions added together: a sharp eased kick over the first quarter, then a linear drift that keeps going. The kick is what sells the break; the drift is what carries the shard off screen.
The fragment shader treats faces and walls differently. Faces sample the snapshot, offset very slightly by the surface normal so the texture appears to sit inside a thickness of glass rather than painted on it. Walls ignore the texture entirely and render a flat edge colour plus a hard Fresnel-and-specular highlight. Two light directions, a tight exponent of 70, and the rim lights up as it tumbles.
Step five: the hand-off
This is the part that is easy to get wrong and impossible to un-see once it is. The order matters:
- render frame zero: the shards, still perfectly assembled, an exact copy of the live screen;
- dissolve that sheet in over the screen it is copying, 150 ms, linear;
- then swap the page underneath, once the sheet is opaque.
Do it in any other order and there is a frame where neither the old screen nor the new one is on top, and it flashes. The dissolve is not there to be admired: at progress zero the sheet is pixel-identical to what it covers, so in a browser that paints a canvas exactly you cannot see it happen at all. It is there because arriving instantly is the part you can see, in any browser that treats a canvas even slightly differently from the document.
One subtlety in that third step. Committing on a timer started alongside the fade is wrong, because a CSS transition only begins on the next frame: one slow frame and the swap lands while the sheet is still translucent, which is exactly the flash the ordering exists to prevent. I measured it doing precisely that, swapping at 33 per cent opacity. Commit on transitionend instead, and demote the timer to a fallback for the case where no transition runs at all.
In SvelteKit this maps onto onNavigate almost exactly. Returning a promise there holds the DOM update until you resolve it, which is precisely the hand-off point above:
onNavigate((navigation) => {
if (!shatter || motion.reduced) return;
return new Promise((resolve) => shatter.run(resolve));
}); Everything that can go wrong
A transition that sometimes fails to complete is far worse than no transition. The commit (the callback that actually swaps the page) is wrapped so it runs exactly once, no matter which path gets there:
- the snapshot races a three-second timeout; if it loses, the page just cuts;
- a four-second failsafe fires the commit even if the capture never settles;
- a second failsafe tears the shards down at duration plus 900 ms if a frame loop stalls;
- if three.js or html-to-image fail to load at all, the commit runs immediately and navigation is normal.
Renderers are pooled, two deep. Creating a WebGLRenderer per navigation means a new GL context per navigation, and browsers cap how many you can have. Get it wrong and the oldest contexts start getting killed out from under you.
Then there is the one that only appears on real hardware. Every shard flies through the camera: aZSpeed reaches about 11 world units against a camera parked at 6. At the instant a vertex lands on the camera, the vector from that vertex to the camera has zero length, and normalize() on a zero-length vector is undefined per the GLSL spec. Drivers that answer NaN poison every fragment of that triangle, so a shard covering most of the viewport turns opaque black. Fading the highlight out does not save you either, because NaN * 0 is still NaN. Software rasterisers hand back a zero vector instead, so nothing goes wrong on a machine that is not driving a real GPU, and a bug like this reaches you as a report from somebody else's screen rather than as something you saw for yourself. Both shader stages now normalise through a guard with an explicit fallback direction.
Colour management, and one browser that disagrees
three.js applies colour management by default, decoding sRGB textures into linear working space. Since every colour here comes from a canvas that was already composited in sRGB, that decode shifts the whole palette. The site disables it globally and sets the renderer to LinearSRGBColorSpace, so what goes into the texture is what comes out of the shader.
All of which holds right up until the browser paints the canvas. In the Zen build I use, a Firefox fork, the dark theme's #12120e ground arrives on screen as #030303. The 2D composite holds rgba(18,18,14,255). gl.readPixels on the finished frame holds the same value. gl.getError() is clean. Every stage the page can actually inspect is correct, so the shift is happening in the compositor, after the pixels leave. The arithmetic points at an sRGB-to-linear decode applied once: that curve crushes darks and barely touches lights, 18 becomes 3 while 236 becomes 209, which is exactly why only the dark theme's background looks broken while the text riding on the shards looks fine.
It does not reproduce in any other browser I can point at the page, Chromium included, so this is something about that install's compositing path rather than a claim about Gecko in general. What it is not, in any case, is fixable from inside the page. It cannot even be detected there: the only readback available already reports the right answer. That leaves a straight choice. Keep the ground inside the shard texture, which is what makes every shard a solid pane, or paint it as a DOM layer beneath see-through shards, which is immune but turns the flying pieces into floating text. The panes won, and the artefact stays. That everything else gets it right is a useful reminder that "works in every browser I can test" is a smaller claim than it sounds.
Was it worth it
For a client site, no. For a portfolio whose whole argument is I will actually go and figure this out, yes, obviously.