Portfolio case study · interview talking point

How this 3D scroll site was built

A production-minded walkthrough of scroll-scrubbed video: why ordinary H.264 fails under scrubbing, how all-intra re-encoding + ScrollTrigger math fix it, and when the canvas path takes over. Every number below comes from measurements I took — nothing invented.

Andrew Strachan Measured on my MacBook Pro in Chrome · Sept 28, 2026 ← Portfolio home

Goal and one-sentence architecture

I built a presentation-ready portfolio where scroll position drives a short 3D clip frame-by-frame, hosted as static files with near-zero ongoing cost. The source clip was generated with Gemini; I re-encoded and wired the scroll experience myself.

Architecture: pre-rendered clip → all-intra re-encode → GSAP ScrollTrigger maps scroll progress to frame → canvas fallback → static hosting.

Scroll-scrub pipeline Source clip is re-encoded all-intra, then ScrollTrigger maps progress to a frame. Video path seeks; canvas path draws WebP frames from an LRU cache. Static hosting serves Range requests. Source clip All-intra encode ScrollTrigger progress→frame Video seek Canvas LRU draw Static host

Why a normal video scrubs badly

The source file WebAnimation.mp4 is 1920×1080 H.264, 24 fps, 5.502 s, 134 frames, ~8.6 MB, with 1 keyframe for the whole clip. Seeking to an arbitrary frame forces the decoder to rebuild from that lone I-frame — fine for playback, terrible for scroll scrubbing.

The fix: I re-encoded all-intra (a keyframe on every frame). Measured encodes report 134/134 I-frames.

Source vs encoded asset sizes
Asset Size Notes
Source WebAnimation.mp4 ~8.6 MB 1 keyframe / 134 frames
scrub-1280-intra.mp4 8,562,955 B (8.17 MB) H.264 1280×720, CRF 17, all I
scrub-1920-intra.mp4 12,302,083 B (11.73 MB) H.264 1920×1080, CRF 20, all I
scrub.webm 10,155,526 B (9.69 MB) VP9 1280×720, CRF 35, all I
poster.webp 56,342 B (~55–56 KB) LCP stand-in; budget ≤200 KB

Decode note: ffmpeg produced 133 frames from the source; I duplicated frame 134 so the sequence stays 134 frames long.

The scroll math

GSAP ScrollTrigger pins .scrub-pin with start: "top top", pin length +=500% desktop / +=350% at max-width 720px (I switched away from ignored vh units), and scrub: 0.35 for smoothing.

frame = Math.round(progress * (frames - 1))
// seek / draw only when frame changes
currentTime = frame / fps   // quantized

Progress is clamped to [0, 1]. Chapters use half-open windows [start, end) from the chapter markup.

Progress
0.000
Frame (0-based)
0 / 133
Timestamp
0.000 s
Chapter
Build

Chapters: Build [0–0.20) · Explain [0.20–0.40) · Protect [0.40–0.60) · AI [0.60–0.80) · Cloud [0.80–1]

Two render paths and the decision gate

Video vs canvas

Default mode is video (seek all-intra MP4/WebM). Initial canvas instead when iOS/iPad/Safari, deviceMemory < 4, or hardwareConcurrency ≤ 4. Runtime auto-degrade switches video → canvas if seek latency or drop ratio exceeds thresholds (drop ratio > 0.2 after ≥15 samples).

LRU memory math

An ImageBitmap at ~1280×720 is about ~3.7 MB. Caching all 134 frames would be ≈495 MB. ScrubCanvas keeps an LRU window of ~40 decoded bitmaps (24 on low-memory), not the full sequence.

Measured results (MacBook Chrome)

Scroll harness metrics
Pass Median fps Match Seek med / p95 Presented / dropped
video-slow-fwd 120.5 1.00 8.1 / 9.4 ms 107 / 0
video-med-fwd 120.5 1.00 7.8 / 9.5 ms 107 / 0
video-flick-fwd 120.5 1.00 7.4 / 10.0 ms 39 / 68
video-med-fwd-4x 120.5 1.00 10.3 / 11.7 ms 108 / 0
canvas-med-fwd 120.5 1.00 — — (LRU cached 40)

Decision gate: keep-video-default (video medium median page fps 120.5, matched-frame fraction 1.0; thresholds ≥55 fps / ≥0.9).

Honest limits. iPhone Safari was not tested — device verification still required. The flick pass dropped frames in video mode (39 presented / 68 dropped), which is exactly why auto-degrade to canvas exists. Canvas heap after a full pass: ~2.16 MB JS heap with LRU cached 40.

Accessibility and performance guardrails

  • prefers-reduced-motion: no pin; poster; stacked chapters; live region once.
  • saveData: no video/frame fetch; poster + stacked chapters.
  • Skip link: #scrub-skip → #overview verified keyboard path.
  • Live region: #scrub-live polite announcements.
  • Budgets: poster 56 KB vs 200 KB budget; site JS excluding GSAP gzip 17,853 B (≤120 KB); pre-scroll transfer 495 KB (video/frames not requested).
  • Lazy loading: video sources use data-src + preload="none"; IntersectionObserver attaches media when #scrub enters the viewport (lazyRootMargin: 0px).

Delivery: static hosting plan

Hosting design is private S3 + CloudFront (OAC) with byte-range support for MP4 seeking. CloudFront forwards Range by default.

Security headers policy includes HSTS, X-Content-Type-Options, X-Frame-Options: SAMEORIGIN, referrer policy, CSP, XSS protection, and optional X-Robots-Tag: noindex. PriceClass_100 + static objects keep cost near zero aside from transfer.

Production-grade 3D scroll website checklist

Derived from what I actually measured and shipped for this site.

  1. Measure source keyframes; re-encode all-intra if scrubbing is required.
  2. Ship dual resolutions + WebM with a light poster as LCP.
  3. Map progress → frame once; seek only on frame change.
  4. Pin with ScrollTrigger; use % pin ends if vh is ignored.
  5. Add canvas frame-sequence fallback with bounded LRU (≈40, not 134).
  6. Decision gate on real devices; keep video default only if fps/match clear thresholds.
  7. Auto-degrade when flick scrub drops frames or seeks get slow.
  8. Honor reduced-motion and saveData with a static poster path.
  9. Lazy-attach media; keep pre-scroll transfer lean (poster yes, video no).
  10. Serve with Range-capable static hosting + security headers; keep noindex until indexing is approved.