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.
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.
| 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.
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)
| 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).
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→#overviewverified keyboard path. -
Live region:
#scrub-livepolite 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#scrubenters 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.
- Measure source keyframes; re-encode all-intra if scrubbing is required.
- Ship dual resolutions + WebM with a light poster as LCP.
- Map
progress → frameonce; seek only on frame change. - Pin with ScrollTrigger; use
%pin ends ifvhis ignored. - Add canvas frame-sequence fallback with bounded LRU (≈40, not 134).
- Decision gate on real devices; keep video default only if fps/match clear thresholds.
- Auto-degrade when flick scrub drops frames or seeks get slow.
- Honor reduced-motion and saveData with a static poster path.
- Lazy-attach media; keep pre-scroll transfer lean (poster yes, video no).
- Serve with Range-capable static hosting + security headers; keep
noindexuntil indexing is approved.