🎞️ 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";
3
4/* ============================================================
5 SCROLL CINEMATIC — the Apple-product-page signature move:
6 a video that plays FRAME BY FRAME as you scroll.
7
8 How it works (the whole trick):
9 1. Load an MP4 (generated here with AI — Seedance 2.0 via
10 the Higgsfield MCP, from a Nano Banana keyframe).
11 2. At load time, seek through it and cache N frames as
12 ImageBitmaps (GPU-friendly) — no ffmpeg, no 100 JPEGs.
13 3. A tall (400svh) section pins a canvas; scroll progress
14 picks the frame; a lerp smooths the playhead so it feels
15 like silk even between frames.
16 If the video can't load, a procedural ink-sigil animation is
17 generated in-browser so the demo never breaks.
18 ============================================================ */
19
20const VIDEO_SRC = "/videos/scroll-cinematic.mp4";
21const FRAMES = 72; // cached frame count — more = smoother + heavier
22
23const 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];
28
29export default function Scrub() {
30 const wrapRef = useRef(null);
31 const canvasRef = useRef(null);
32 const [phase, setPhase] = useState("loading"); // loading | ready | fallback
33 const [pct, setPct] = useState(0);
34 const progressRef = useRef(0);
35
36 useEffect(() => {
37 const canvas = canvasRef.current;
38 const ctx = canvas.getContext("2d");
39 let frames = [];
40 let raf = 0;
41 let dead = false;
42 let current = 0; // smoothed playhead
43
44 const fit = () => {
45 const dpr = Math.min(window.devicePixelRatio || 1, 2);
46 canvas.width = canvas.clientWidth * dpr;
47 canvas.height = canvas.clientHeight * dpr;
48 };
49 fit();
50
51 /* ---- 1. extract frames from the video ---- */
52 const extract = async () => {
53 const video = document.createElement("video");
54 video.src = VIDEO_SRC;
55 video.muted = true;
56 video.playsInline = true;
57 video.preload = "auto";
58 await new Promise((res, rej) => {
59 video.onloadedmetadata = res;
60 video.onerror = rej;
61 setTimeout(rej, 8000); // don't hang forever
62 });
63 const dur = video.duration;
64 // decode-friendly capture size: cap at ~900px wide
65 const scale = Math.min(1, 900 / video.videoWidth);
66 const fw = Math.round(video.videoWidth * scale);
67 const fh = Math.round(video.videoHeight * scale);
68 const off = document.createElement("canvas");
69 off.width = fw;
70 off.height = fh;
71 const octx = off.getContext("2d", { willReadFrequently: false });
72
73 for (let i = 0; i < FRAMES; i++) {
74 if (dead) return [];
75 const t = (i / (FRAMES - 1)) * Math.max(0, dur - 0.05);
76 await new Promise((res) => {
77 video.onseeked = res;
78 video.currentTime = t;
79 });
80 octx.drawImage(video, 0, 0, fw, fh);
81 frames.push(await createImageBitmap(off));
82 setPct(Math.round(((i + 1) / FRAMES) * 100));
83 }
84 return frames;
85 };
86
87 /* ---- fallback: procedurally render an ink-sigil orbit ---- */
88 const synthesize = async () => {
89 const fw = 900, fh = 506;
90 const off = document.createElement("canvas");
91 off.width = fw;
92 off.height = fh;
93 const o = off.getContext("2d");
94 for (let i = 0; i < FRAMES; i++) {
95 const p = i / (FRAMES - 1);
96 const a = p * Math.PI * 2;
97 o.fillStyle = "#0a0a12";
98 o.fillRect(0, 0, fw, fh);
99 for (let ring = 0; ring < 3; ring++) {
100 const hue = ["#7c3aed", "#06b6d4", "#ffa500"][ring];
101 o.strokeStyle = hue;
102 o.lineWidth = 3 - ring * 0.7;
103 o.globalAlpha = 0.85;
104 o.beginPath();
105 for (let k = 0; k <= 120; k++) {
106 const u = (k / 120) * Math.PI * 2;
107 const wob = Math.sin(u * (3 + ring) + a * (ring + 1)) * (26 - ring * 6);
108 const r = 130 + ring * 36 + wob;
109 const x = fw / 2 + Math.cos(u + a * (ring % 2 ? -1 : 1)) * r * 1.35;
110 const y = fh / 2 + Math.sin(u + a * (ring % 2 ? -1 : 1)) * r * 0.78;
111 k === 0 ? o.moveTo(x, y) : o.lineTo(x, y);
112 }
113 o.closePath();
114 o.stroke();
115 }
116 o.globalAlpha = 1;
117 frames.push(await createImageBitmap(off));
118 setPct(Math.round(((i + 1) / FRAMES) * 100));
119 }
120 return frames;
121 };
122
123 /* ---- 2. scroll → frame, with lerp smoothing ---- */
124 const onScroll = () => {
125 const el = wrapRef.current;
126 if (!el) return;
127 const r = el.getBoundingClientRect();
128 const total = r.height - window.innerHeight;
129 progressRef.current = Math.min(1, Math.max(0, -r.top / total));
130 };
131
132 const draw = () => {
133 raf = requestAnimationFrame(draw);
134 if (!frames.length) return;
135 const target = progressRef.current * (frames.length - 1);
136 current += (target - current) * 0.18; // the silk
137 const f = frames[Math.round(current)];
138 if (!f) return;
139 // cover-fit
140 const cw = canvas.width, ch = canvas.height;
141 const s = Math.max(cw / f.width, ch / f.height);
142 const dw = f.width * s, dh = f.height * s;
143 ctx.clearRect(0, 0, cw, ch);
144 ctx.drawImage(f, (cw - dw) / 2, (ch - dh) / 2, dw, dh);
145 };
146
147 extract()
148 .then((fr) => {
149 if (!fr.length) throw new Error("aborted");
150 setPhase("ready");
151 })
152 .catch(async () => {
153 frames = [];
154 await synthesize();
155 if (!dead) setPhase("fallback");
156 })
157 .finally(() => {
158 onScroll();
159 draw();
160 });
161
162 window.addEventListener("scroll", onScroll, { passive: true });
163 window.addEventListener("resize", fit);
164 return () => {
165 dead = true;
166 cancelAnimationFrame(raf);
167 window.removeEventListener("scroll", onScroll);
168 window.removeEventListener("resize", fit);
169 frames.forEach((f) => f.close?.());
170 };
171 }, []);
172
173 return (
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" }} />
177
178 {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 <div
184 className="h-full rounded-full transition-all"
185 style={{ 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 )}
192
193 {/* captions keyed to scroll progress (CSS-only fade via timeline would
194 also work — JS keeps this file self-contained) */}
195 <Captions progressRef={progressRef} />
196
197 <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}
206
207/* caption layer — reads smoothed progress on rAF, pure overlay */
208function Captions({ progressRef }) {
209 const ref = useRef(null);
210 useEffect(() => {
211 let raf = 0;
212 const tick = () => {
213 raf = requestAnimationFrame(tick);
214 const p = progressRef.current;
215 const nodes = ref.current?.children || [];
216 CAPTIONS.forEach((c, i) => {
217 const n = nodes[i];
218 if (!n) return;
219 const mid = (c.at + c.end) / 2;
220 const span = (c.end - c.at) / 2;
221 const vis = Math.max(0, 1 - Math.abs(p - mid) / span);
222 n.style.opacity = vis.toFixed(3);
223 n.style.transform = `translateY(${(1 - vis) * 24}px)`;
224 });
225 };
226 tick();
227 return () => cancelAnimationFrame(raf);
228 }, [progressRef]);
229
230 return (
231 <div ref={ref} aria-hidden="true">
232 {CAPTIONS.map((c) => (
233 <div
234 key={c.title}
235 className="absolute left-1/2 top-[14%] -translate-x-1/2 text-center w-[90%] pointer-events-none"
236 style={{ 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