Requirements, translated to engineering
The previous article covered turning a Spline scene into 48 frames; this one covers turning those frames into an Apple-style frame-sequence hero. The component's spec came from a handful of very ordinary design feedback lines — each translating directly into an engineering decision.
| The designer's words | Engineering answer |
|---|---|
| Rejecting the click-gate: visitors must see the work immediately, no long loading | Poster-first: a priority next/image is the LCP; the canvas sits transparent above it until frame data exists |
| Waits must be short, or something should appear piece by piece | Staggered entrance copy (0.15/0.35/0.6s) masks background fetches; a fixed-duration timeline means slow networks shorten the show, never lengthen the wait |
| Keep playing, and play faster | Endless ping-pong loop at 30fps — the capture isn't a closed loop, a hard cut would visibly jump |
| Just run the whole thing | Untrimmed: the full 48-frame journey stays in the loop |
| Start from a later frame | START_FRAME offset: the timeline opens mid-journey, the poster is re-rendered from that frame, and preload fetches it first |
| Never make anyone wait (the recurring theme) | Any wheel/touch/click/key during the intro jump-cuts the 100svh→40svh shrink; the loop keeps playing |

The technique checklist
- Stride preload order (0,8,16…4,12…2,6…rest, START_FRAME promoted to front): wherever playback or a scrub lands, a nearby frame is already there.
- Nearest-loaded-frame drawing: playback never blanks on a partial set; late arrivals back-fill.
createImageBitmapmoves decode off the render path (INP protection); bitmaps are.close()d on unmount.- The rAF loop is gated by an IntersectionObserver: an endless 30fps loop must not burn battery below the fold; document-hidden pauses come free with rAF.
ResizeObserverre-fits the canvas across the 100svh→40svh height transition and repaints the held frame (device-pixel-ratio capped at 2).height: 100vh; height: 100svh;double-write for iOS toolbars.- Reduced-motion pins the banner layout in a CSS media query — no JS state flip, and the sequence is never fetched; Save-Data/2G reach the same end state via a connection check.
- AVIF with a probe (memoised data-URI decode test) → WebP fallback → JPG poster underneath everything.
- Intro-overlay handoff contract (the
startprop): while false — poster rendered, frames preloading, no playback, no entrance animation, skip gestures disarmed; flipped at the overlay's full-black moment. Pairs with the candle intro (decode-gated, 800ms cap, skip-not-wait). - Two React 19 lint lessons:
react-hooks/refs— no ref reads during render (use closure flags in effects);react-hooks/set-state-in-effect— no synchronous setState in effect bodies (defer via a timer, or pin the state in CSS instead).
Degradation is the design
On old Safari (pre-15, no createImageBitmap): the fetch loop fails silently per frame and the canvas never draws — but the poster, entrance copy and shrink all still run. The visitor gets a quiet static hero, not a broken animation. Degradation is the design, not an accident.
Industry context
Canvas frame-sequence scrubbing/autoplay is the signature Apple product-page technique, commonly implemented with GSAP ScrollTrigger. Native CSS scroll-driven animations have shipped in Chrome/Edge/Safari, but Firefox stable is still behind a flag as of mid-2026 (~84% global support) — which is why the JS path remains the compatible choice. This implementation swaps scroll-driving for time-driving plus input-skip; the same frame infrastructure serves both.
The numbers
48 frames; 337KB total desktop WebP (~7KB per frame); 141KB mobile; 27.9KB poster; one pass runs 1.6s at 30fps. Beyond the component itself the hero adds zero client-side JS — framer-motion was already in the bundle, and the component only uses useReducedMotion.