Quick Start
1. Add to your HTML <head>:
Code snippet omitted: it ships with the download.
2. Add before closing </body> tag:
Code snippet omitted: it ships with the download.
3. Add the effect HTML anywhere in your <body>:
Code snippet omitted: it ships with the download.
Using It With Your Own Design
What the script needs. A [data-reel] section containing [data-reel-stage] (the element that gets pinned), [data-reel-video] (the film) and [data-reel-track] holding one .reel__chapter per panel, each with a .reel__chapter-inner. .reel__chapter and .reel__chapter-inner are the two class names the script reads, for the per-chapter reveals; everything else it touches is a data- hook. [data-reel-frame], [data-reel-rail] and [data-reel-hint] are optional: leave them out and the counter, rail and prompt simply do not run.
What is only the demo. The "Nightfall" label, the chapter times, the rail ticks, the copy and the dark ground are the demo's page. Delete or restyle them freely. The .reel__rail-tick spans are plain CSS marks at --at percentages; they are not driven by the script.
CSS the effect depends on. The base layout (no JS, reduced motion, touch) stacks the chapters over a position: sticky video: keep .reel__track { margin-top: -100vh } and the sticky video, or the film and the captions separate into two blocks. When the script adds .reel--scrub, the stage becomes height: 100vh; overflow: hidden, the track becomes a width: max-content flex row of 100vw chapters, and the video goes position: absolute; inset: 0. Those rules are what the pin and the horizontal travel measure against, so keep them whatever you do to colour and type. The chapters carry no start-state pre-hide in CSS: their reveal is a scrubbed gsap.from, so no .has-js gate is needed and nothing flashes without JavaScript. The velocity smear is filter: blur(var(--reel-smear)) on the track plus a skewX the script sets directly; drop the filter line if you only want the skew.
Tokens. Colours and type live on :root (--page, --ink, --accent, --font-sans). The accent is used on the rail fill, the chapter marker rule and the prompt dot only; swap it once and the whole demo follows.
Preparing the video (important)
Browser video seeking only feels smooth when every frame is a keyframe. Encode your clip all-intra (a keyframe on every frame) before shipping it, or the scrub will stutter as the browser decodes from distant keyframes.
Code snippet omitted: it ships with the download.
Keep it short (8-12s), mute it (-an), and prefer 960px wide at 24fps: an all-intra file is larger than a normal MP4, so resolution, frame rate and length are your budget (the demo's 12s clip at 960x540 is about 3.3MB). Footage with a clear change from first frame to last (a timelapse, a drone push, a process) is what makes the scrub read; a loop that looks the same at either end reads as nothing happening. Update the 288 in the frame counter markup and totalFrames in the script to your clip's frame count (duration x fps), and frameDur if it is not 24fps.
Options
Set on the .reel element:
| Attribute | Values | Default | Description |
|---|---|---|---|
data-reel-scrub |
Any number (seconds) | 0.6 |
Scrub smoothing. 0 locks 1:1 to scroll; higher eases the catch-up |
data-reel-length |
Any number | 0.85 |
Vertical scroll distance as a multiple of the horizontal track travel. Higher = slower, longer scrub |
data-reel-velocity |
0 to 2 |
1 |
Strength of the skew/blur smear on fast scroll. 0 disables it |
Chapter alignment
Add a modifier to each .reel__chapter to place its caption (keeps successive panels from feeling identical). Captions sit in the lower third of the film, above the rail, where the scrim gives them a floor:
| Class | Placement |
|---|---|
.reel__chapter--left |
Left, lower third |
.reel__chapter--right |
Right, lower third |
.reel__chapter--center |
Centred, lower third |
.reel__chapter--low |
Pushed to the floor, just above the rail (combine with --left/--center) |
How it works
A single ScrollTrigger pins .reel__stage and scrubs a timeline. The timeline translates .reel__track sideways by its measured scrollWidth and pushes the video in slightly. In the trigger's onUpdate, scroll progress is mapped onto video.currentTime; each seek sets an awaitingSeek flag that the video's seeked event clears, so seeks never queue up faster than the decoder can serve them. Scroll velocity from getVelocity() drives a decaying skewX and blur on the track.
Accessibility
- Reduced motion:
prefers-reduced-motion: reduceholds the poster frame and lays the chapters out as a readable vertical list, with no pinning, scrubbing, or autoplay. - Touch devices: seeking a video from touch-scroll is janky, so coarse pointers get the film autoplaying muted and looped as a backdrop, with the chapters stacked and revealed on scroll.
- No JavaScript: the base layout is the stacked, fully visible list, so the content is never hidden behind a class the script must add.
- Screen readers: the decorative film and HUD are
aria-hidden; the chapter headings and copy remain in the reading order.
Dependencies
Required:
- GSAP 3.12+
- ScrollTrigger plugin
Optional:
- Lenis (smooth scroll; the scrub reads noticeably smoother with it)
Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.