Skip to content
← Back to portfolio

Engineering an Apple-style frame-sequence hero in React

Published9 min
ReactJSDesign-to-CodeWeb PerformanceAnimation

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 wordsEngineering answer
Rejecting the click-gate: visitors must see the work immediately, no long loadingPoster-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 pieceStaggered 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 fasterEndless ping-pong loop at 30fps — the capture isn't a closed loop, a hard cut would visibly jump
Just run the whole thingUntrimmed: the full 48-frame journey stays in the loop
Start from a later frameSTART_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 hero's poster frame: KARENYA LIN in large 3D letters, face-on, in a violet starfield
The poster is the LCP; the canvas playback layers on top only when frames actually exist.

The technique checklist

  1. 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.
  2. Nearest-loaded-frame drawing: playback never blanks on a partial set; late arrivals back-fill.
  3. createImageBitmap moves decode off the render path (INP protection); bitmaps are .close()d on unmount.
  4. 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.
  5. ResizeObserver re-fits the canvas across the 100svh→40svh height transition and repaints the held frame (device-pixel-ratio capped at 2).
  6. height: 100vh; height: 100svh; double-write for iOS toolbars.
  7. 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.
  8. AVIF with a probe (memoised data-URI decode test) → WebP fallback → JPG poster underneath everything.
  9. Intro-overlay handoff contract (the start prop): 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).
  10. 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.