CineScroll

scrollcanvascinematicimage-sequencescrollytelling

Scroll is the camera. Pin the viewport, feed it frames, and the page becomes one unbroken shot.

Scroll is the camera: a pinned viewport scrubs a frame sequence with double-exposure blends, velocity whip blur and DOM chapter overlays. Bring your own frames and chapters — photos, renders or locally generated ComfyUI plates.

Playground

A last-train story · 終電

SHŪDEN

She missed the 23:47. The city had one more way home.

No trains till dawn — four hours, a sleeping city, and a white fox who knew every gate. She follows it down the stairs and out of the timetable.

The night line

SEVEN STOPS TO SUNRISE

23:58Kandawhere the fox waited
01:12Sumidathe long bridge
02:46Hanazonoa coin for passage
Stations
(7)
Gates
(3)
Foxes
(1)

The 4:07 arrives with the sun.

scroll — the scrollbar is the camera

Tweak the props in the toolbox — the preview updates in realtime. “Copy code” gives you the exact JSX for the current tweaks.

Installation

Requires the shadcn CLI. Run this from your project root — it writes the component source, then you import and go.

bash
npx shadcn@latest add "https://webcules.com/r/cine-scroll.json"

The command installs the component source (plus its engine / type files when the component ships one). No other dependencies — everything is dependency-free canvas, SVG and CSS.

Usage

Import the component and drop it in — every prop in the toolbox is a regular React prop:

hero.tsx
import { CineScroll, CineIndexRows } from "@webcules/ui/components/ui/cine-scroll";
const frames = [
"/plates/shot-01.webp",
"/plates/shot-02.webp",
"/plates/shot-03.webp",
"/plates/shot-04.webp",
];
export function FilmHero() {
return (
<CineScroll
frames={frames}
height="500vh"
blend="exposure"
motionBlur={0.6}
chapters={[
{
from: 0,
to: 0.2,
align: "center",
content: (
<div className="text-center text-white">
<p className="text-[11px] uppercase tracking-[0.4em]">A film you scroll through</p>
<h1 className="mt-3 text-6xl font-semibold">STUDIO®</h1>
</div>
),
},
{
from: 0.3,
to: 0.55,
align: "left",
content: <p className="text-xl text-white">Impossible scenes, told like they happened.</p>,
},
{
from: 0.65,
to: 0.9,
align: "right",
content: (
<CineIndexRows
rows={[
{ year: "2025", title: "Hayate", note: "No detours" },
{ year: "2024", title: "Tsuki", note: "One last train" },
]}
/>
),
},
]}
/>
);
}

Highlights

  • Scroll is the camera: a tall section pins its stage while the scrollbar scrubs an ordered frame sequence — photos, renders or AI plates, your media
  • Adjacent plates double-expose into each other; fast scrolling adds a directional whip blur, slow scrolling breathes with a Ken Burns push-in
  • Chapters are real DOM (selectable, indexable) that fade and rise inside progress windows — pair with the exported CineIndexRows and CineStat primitives
  • Works as a page hero (window scroll) or embedded: set stageHeight to the container viewport and it scrubs inside any scroll box
  • Canvas 2D, zero deps; one RAF parked off-screen/hidden-tab; reduced-motion keeps scroll→frame direct manipulation with no autonomous motion

Example — Make the plates with local ComfyUI

Generate a consistent camera move: plate 1 from text, every next plate as img2img off the previous one (denoise ≈0.5) with a per-beat camera prompt. Same style + cast block verbatim in every prompt is what makes the scrub read as one scene.

example.tsx
// research/generate-plates.ts in the social-forge project —
// beats: wide → tumble close-up → car pass → whip close → slide → leap
const style = "1990s Japanese anime film still, cel animation, dusk city, …";
const beats = ["sprinting toward camera, wide", "close-up, dutch angle", "…"];
// plate 1: txt2img · plate n: img2img(plate n−1, denoise 0.5)

Example — Stat counters over the final shot

Export CineStat gives the small-caps label over the huge serif-italic numeral from the reference.

example.tsx
import { CineScroll, CineStat } from "@webcules/ui/components/ui/cine-scroll";
<CineScroll
frames={frames}
chapters={[
{
from: 0.84,
to: 1,
align: "center",
at: "bottom",
content: (
<div className="flex gap-14">
<CineStat label="Clients" value="73" />
<CineStat label="Sets built" value="208" />
</div>
),
},
]}
/>

Props

PropTypeDefaultDescription
videostring""Optional clip URL — the scrub seeks the video with scroll instead of the frames. Leave empty for the SHŪDEN frame set.
blend"exposure" | "crossfade""exposure"exposure = lighten double-exposure between plates (the reference look); crossfade = plain alpha. No effect in video mode.
motionBlurnumber (0–1)0.6Velocity-scaled directional blur while you scroll fast — the 'one unbroken camera move' feel.
driftnumber (0–0.15)0Ken Burns push-in/pan per plate so stills breathe. Off by default — video frames are already alive.
scrollSmoothingnumber (0–0.4)0.14Lerped scrub lag. 0 = raw scrollbar, ~0.14 = filmic.
vignettenumber (0–0.8)0.35Edge darkening — unifies mixed-source plates into one grade.
grainnumber (0–0.4)0.12Animated film grain (deterministic per frame).

Notes

  • Bring your own media: pass frames (any image URLs, ≈8–16 plates, webp, 16:9) or a single video URL as video — the scrub then seeks the clip with scroll. The SHŪDEN demo frames are cut from one locally generated Wan 2.2 image→video camera move; the reference site runs a real-time 3D scene, the scrub pattern is identical.
  • Chapters are progress windows (0–1). A chapter starting at from: 0 is visible before any scroll; others fade/rise inside their window.
  • Chapters are progress windows (0–1). A chapter starting at from: 0 is visible before any scroll; others fade/rise inside their window.
  • The canvas is aria-hidden; chapter copy is real DOM and stays readable by screen readers at every scroll position.
  • Prefer reduced motion: scrubbing becomes direct manipulation — scroll still swaps plates instantly, with no smoothing, blur, drift or grain animation.
  • Plates are decoded once into offscreen canvases (capped 1600px wide) and DPR is capped at 2; the RAF loop parks when the section is off-screen or the tab is hidden.