A scroll-driven 3D product site, and the layout shift that took three attempts
A fourteen-section, roughly 12,900-pixel scroll experience for a direct-to-consumer skincare brand: scroll-scrubbed hero video, a glass navigation bar that morphs on scroll, per-character headline reveals using a variable font's optical and softness axes, and a procedural 3D product jar in WebGL.
A note on attribution
This was built as a pitch and was never signed, so the brand is not named here and the live build carries a noindex directive — it should never compete with the client's own store in search results. It is shown as a concept piece because the engineering is worth showing, not to imply a client relationship that does not exist.
The layout shift, and three wrong answers
Cumulative layout shift started at 0.26. Lighthouse pointed at the hero copy block. We tried the obvious font-swap fix — preloads and metric-matched fallbacks — and it changed nothing. We tried image sizing. Also nothing.
The answer only appeared when we stopped trusting the tooling and sampled the heading's bounding box on every animation frame, printing the frames where it changed: 333 pixels, then 463, then back to 333. The text-splitting library was re-wrapping a two-line headline into three lines and back as it applied.
Two fixes. Split by lines and words as well as characters, so a line can never break mid-word. And pin the height outright rather than setting a min-height, because a minimum still permits growth. 0.26 became 0.019.
| Metric | Mobile | Desktop |
|---|---|---|
| Lighthouse performance | 82 | 95 |
| Accessibility | 100 | 100 |
| Cumulative layout shift | 0.019 | 0.072 |
| Total blocking time | 400 ms | 160 ms |
Deferring 3D properly
Total blocking time began at 1,510 ms. The instinct is to lazy-load the 3D, and the framework offers a dynamic import with server rendering disabled that looks like it does exactly that. It does not — the import fires on hydration, and every section hydrates. The 3D library was loading regardless.
It needed an intersection-observer gate on top. That, plus removing a convenience helper that quietly pulled a large environment map from a third-party CDN, brought blocking time to 160 ms.
Later, the 3D jar still felt slow to appear even once cached, because WebGL boot — shader compilation and first render — is visible time. We captured the jar's actual pixels straight off the live canvas as a 6 KB transparent image, showed that instantly, and faded the interactive scene in over it. The section is never empty, and because the poster is a frame of the same jar, the swap is seamless.
Judgement calls we would make again
The 1.6 MB hero video is desktop-only; phones get the poster frame. Scroll-scrubbing video is unreliable on iOS regardless, and it was the single largest mobile transfer.
We told the client the supplied video was AI-generated and its label text was visibly garbled. We framed it so the text never reads, but for a real launch that needed a genuine product clip, and saying so was more useful than quietly working around it.
Related
Want this built?
Tell us what you are trying to ship and we will tell you honestly whether we are the right team for it.