🎞️ AI video · scroll scrubbing
Scroll Cinematic
The Apple-product-page signature move. A Seedance 2.0 clip (generated from a Nano Banana keyframe via the Higgsfield MCP) is sliced into 72 GPU bitmaps at load — in the browser, no ffmpeg — then the scrollbar becomes the playhead, with a lerped frame index so it feels like silk.
Use it for: Product launches · luxury brands · cinematic storytelling
slicing video into frames…
0%
scroll to scrub the film ↓
The complete source
This is the exact file rendering the demo above — a single self-contained React client component with its styles inline. Copy or download it, drop it into any React / Next.js project, and it runs. No extra dependencies.
scrub.jsx
1"use client";2import { useEffect, useRef, useState } from "react";34/* ============================================================5SCROLL CINEMATIC — the Apple-product-page signature move:6a video that plays FRAME BY FRAME as you scroll.78How it works (the whole trick):91. Load an MP4 (generated here with AI — Seedance 2.0 via10the Higgsfield MCP, from a Nano Banana keyframe).112. At load time, seek through it and cache N frames as12ImageBitmaps (GPU-friendly) — no ffmpeg, no 100 JPEGs.133. A tall (400svh) section pins a canvas; scroll progress14picks the frame; a lerp smooths the playhead so it feels15like silk even between frames.16If the video can't load, a procedural ink-sigil animation is17generated in-browser so the demo never breaks.18============================================================ */1920const VIDEO_SRC = "/videos/scroll-cinematic.mp4";21const FRAMES = 72; // cached frame count — more = smoother + heavier2223const CAPTIONS = [24{ at: 0.06, end: 0.3, title: "Write whenever,", sub: "the scrollbar is your playhead" },25{ at: 0.38, end: 0.62, title: "wherever,", sub: "72 frames cached as GPU bitmaps" },26{ at: 0.7, end: 0.95, title: "and however you flow.", sub: "generated with AI · scrubbed with scroll" },27];2829export default function Scrub() {30const wrapRef = useRef(null);31const canvasRef = useRef(null);32const [phase, setPhase] = useState("loading"); // loading | ready | fallback33const [pct, setPct] = useState(0);34const progressRef = useRef(0);3536useEffect(() => {37const canvas = canvasRef.current;38const ctx = canvas.getContext("2d");39let frames = [];40let raf = 0;41let dead = false;42let current = 0; // smoothed playhead4344const fit = () => {45const dpr = Math.min(window.devicePixelRatio || 1, 2);46canvas.width = canvas.clientWidth * dpr;47canvas.height = canvas.clientHeight * dpr;48};49fit();5051/* ---- 1. extract frames from the video ---- */52const extract = async () => {53const video = document.createElement("video");54video.src = VIDEO_SRC;55video.muted = true;56video.playsInline = true;57video.preload = "auto";58await new Promise((res, rej) => {59video.onloadedmetadata = res;60video.onerror = rej;61setTimeout(rej, 8000); // don't hang forever62});63const dur = video.duration;64// decode-friendly capture size: cap at ~900px wide65const scale = Math.min(1, 900 / video.videoWidth);66const fw = Math.round(video.videoWidth * scale);67const fh = Math.round(video.videoHeight * scale);68const off = document.createElement("canvas");69off.width = fw;70off.height = fh;71const octx = off.getContext("2d", { willReadFrequently: false });7273for (let i = 0; i < FRAMES; i++) {74if (dead) return [];75const t = (i / (FRAMES - 1)) * Math.max(0, dur - 0.05);76await new Promise((res) => {77video.onseeked = res;78video.currentTime = t;79});80octx.drawImage(video, 0, 0, fw, fh);81frames.push(await createImageBitmap(off));82setPct(Math.round(((i + 1) / FRAMES) * 100));83}84return frames;85};8687/* ---- fallback: procedurally render an ink-sigil orbit ---- */88const synthesize = async () => {89const fw = 900, fh = 506;90const off = document.createElement("canvas");91off.width = fw;92off.height = fh;93const o = off.getContext("2d");94for (let i = 0; i < FRAMES; i++) {95const p = i / (FRAMES - 1);96const a = p * Math.PI * 2;97o.fillStyle = "#0a0a12";98o.fillRect(0, 0, fw, fh);99for (let ring = 0; ring < 3; ring++) {100const hue = ["#7c3aed", "#06b6d4", "#ffa500"][ring];101o.strokeStyle = hue;102o.lineWidth = 3 - ring * 0.7;103o.globalAlpha = 0.85;104o.beginPath();105for (let k = 0; k <= 120; k++) {106const u = (k / 120) * Math.PI * 2;107const wob = Math.sin(u * (3 + ring) + a * (ring + 1)) * (26 - ring * 6);108const r = 130 + ring * 36 + wob;109const x = fw / 2 + Math.cos(u + a * (ring % 2 ? -1 : 1)) * r * 1.35;110const y = fh / 2 + Math.sin(u + a * (ring % 2 ? -1 : 1)) * r * 0.78;111k === 0 ? o.moveTo(x, y) : o.lineTo(x, y);112}113o.closePath();114o.stroke();115}116o.globalAlpha = 1;117frames.push(await createImageBitmap(off));118setPct(Math.round(((i + 1) / FRAMES) * 100));119}120return frames;121};122123/* ---- 2. scroll → frame, with lerp smoothing ---- */124const onScroll = () => {125const el = wrapRef.current;126if (!el) return;127const r = el.getBoundingClientRect();128const total = r.height - window.innerHeight;129progressRef.current = Math.min(1, Math.max(0, -r.top / total));130};131132const draw = () => {133raf = requestAnimationFrame(draw);134if (!frames.length) return;135const target = progressRef.current * (frames.length - 1);136current += (target - current) * 0.18; // the silk137const f = frames[Math.round(current)];138if (!f) return;139// cover-fit140const cw = canvas.width, ch = canvas.height;141const s = Math.max(cw / f.width, ch / f.height);142const dw = f.width * s, dh = f.height * s;143ctx.clearRect(0, 0, cw, ch);144ctx.drawImage(f, (cw - dw) / 2, (ch - dh) / 2, dw, dh);145};146147extract()148.then((fr) => {149if (!fr.length) throw new Error("aborted");150setPhase("ready");151})152.catch(async () => {153frames = [];154await synthesize();155if (!dead) setPhase("fallback");156})157.finally(() => {158onScroll();159draw();160});161162window.addEventListener("scroll", onScroll, { passive: true });163window.addEventListener("resize", fit);164return () => {165dead = true;166cancelAnimationFrame(raf);167window.removeEventListener("scroll", onScroll);168window.removeEventListener("resize", fit);169frames.forEach((f) => f.close?.());170};171}, []);172173return (174<div ref={wrapRef} className="relative" style={{ height: "400svh" }}>175<div className="sticky top-0 h-[100svh] overflow-hidden rounded-3xl border border-white/15">176<canvas ref={canvasRef} className="w-full h-full block" style={{ background: "#0a0a12" }} />177178{phase === "loading" && (179<div className="absolute inset-0 grid place-items-center bg-black/60">180<div className="text-center">181<p className="text-[1.8rem] font-bold text-white">slicing video into frames…</p>182<div className="mt-4 w-[260px] h-[6px] rounded-full bg-white/15 overflow-hidden">183<div184className="h-full rounded-full transition-all"185style={{ width: `${pct}%`, background: "linear-gradient(90deg,#7c3aed,#06b6d4,#ffa500)" }}186/>187</div>188<p className="mt-2 text-[1.3rem] text-white/50">{pct}%</p>189</div>190</div>191)}192193{/* captions keyed to scroll progress (CSS-only fade via timeline would194also work — JS keeps this file self-contained) */}195<Captions progressRef={progressRef} />196197<p className="absolute bottom-4 left-1/2 -translate-x-1/2 text-[1.25rem] text-white/55 bg-black/40 px-4 py-1 rounded-full whitespace-nowrap">198{phase === "fallback"199? "procedural fallback active · scroll to scrub"200: "scroll to scrub the film ↓"}201</p>202</div>203</div>204);205}206207/* caption layer — reads smoothed progress on rAF, pure overlay */208function Captions({ progressRef }) {209const ref = useRef(null);210useEffect(() => {211let raf = 0;212const tick = () => {213raf = requestAnimationFrame(tick);214const p = progressRef.current;215const nodes = ref.current?.children || [];216CAPTIONS.forEach((c, i) => {217const n = nodes[i];218if (!n) return;219const mid = (c.at + c.end) / 2;220const span = (c.end - c.at) / 2;221const vis = Math.max(0, 1 - Math.abs(p - mid) / span);222n.style.opacity = vis.toFixed(3);223n.style.transform = `translateY(${(1 - vis) * 24}px)`;224});225};226tick();227return () => cancelAnimationFrame(raf);228}, [progressRef]);229230return (231<div ref={ref} aria-hidden="true">232{CAPTIONS.map((c) => (233<div234key={c.title}235className="absolute left-1/2 top-[14%] -translate-x-1/2 text-center w-[90%] pointer-events-none"236style={{ opacity: 0 }}237>238<p className="text-[3.4rem] md:text-[5rem] font-black text-white leading-tight" style={{ textShadow: "0 4px 30px rgba(0,0,0,.8)" }}>239{c.title}240</p>241<p className="text-[1.5rem] text-white/65 mt-2">{c.sub}</p>242</div>243))}244</div>245);246}247