English Β· TΓΌrkΓ§e
Scroll-driven canvas frame sequence β zero dependencies, drop-in for any site.
A self-contained package that turns a sequence of numbered WebP frames (extracted from a video) into a sticky, scroll-driven hero banner. Works on any page, at any insertion point, with no framework required.
π¨βπ» Created by Remilya π Need a premium website redesign or custom AI-agent integrations? Hire me.
Live: remilya.dev β the hero banner on the homepage is this engine running in production.
GIF is sped up and cut to 8 fps to keep it small. Full-quality recording: demo/scroll-hero-demo.mp4 (1 MB, 11 s, no audio).
# 1. Generate frames from a video (requires ffmpeg)
# Windows:
powershell -File scripts/extract-frames.ps1 -InputVideo path/to/video.mp4 -OutputDir example/frames -DesktopWidth 1280 -DesktopHeight 720
# macOS/Linux:
chmod +x scripts/extract-frames.sh && ./scripts/extract-frames.sh -i path/to/video.mp4 -o example/frames -w 1280 -h 720
# 2. Open the demo page (example/index.html reads ./frames)
open example/index.html
# 3. Optional: check the engine still behaves
node test/smoke.mjsThat's it. The engine auto-initialises any element with data-scroll-hero.
Sizes are a ceiling, not a target: don't ask for frames larger than your source β 1920Γ1080 from a 720p clip is pure weight. Both scripts warn if you do.
No sample video or pre-rendered frames ship with the repo β point the extractor at your own clip and the demo page picks the frames up from example/frames.
Scroll position is mapped linearly onto the clip's timeline, so the source footage decides whether the effect feels premium or cheap.
What the engine wants
- One continuous shot, no cuts, no scene changes.
- 5β15 seconds. Longer clips mean more frames for the same scroll distance, not a longer animation.
- A camera move that travels in a single direction β pull back, push in, slow orbit, crane down. A shot that reverses direction reads as a glitch once the user scrubs it.
- 16:9, 1080p or better. The extractor can only downscale; it warns if you ask for more than the source has.
- A strong opening frame:
frame-001doubles as the poster and is all that reduced-motion, Save-Data and no-JavaScript visitors ever see.
Generating one with AI (fastest route, no camera needed)
- Google Flow / Veo β free tier, currently the easiest way to get a usable clip.
- Seedance 2.5, Kling, Runway, Sora β any capable text-to-video or image-to-video model works.
Prompt tips: describe one continuous camera movement and say so explicitly ("a single continuous shot, no cuts, the camera slowly pulls back"). Feeding a reference still or a storyboard image of the move you want helps far more than adjectives. Expect to iterate β models like to sneak in cuts and to recreate reference images literally.
Or build it by hand β animate a still or a composition in After Effects / Premiere Pro, render a camera move out of Blender or Cinema 4D, or just shoot a real dolly or drone pass. Anything that exports an MP4 is fine.
Then extract the frames
./scripts/extract-frames.sh -i assets/hero-source.mp4 -o example/frames -n 48 -w 1280 -h 720Set data-frame-count to the same number you passed to -n (default 48; 36β60 is the useful
band β more frames buy smoothness and cost bytes). Use -s / -d to trim so the interesting
part of the move spans the whole scroll. Dark, soft, motion-blurred footage compresses
dramatically better than bright high-frequency detail; aim for β€ ~1β2 MB of frames on mobile
and ~3β5 MB on desktop.
Keep native scrolling in charge β the engine only reads scroll position and never hijacks it. Do not layer a scroll-jacking library on top.
Background reading: Steve Sewell's "Create a 3d scrolling animation with GSAP and Veo 3" on the Builder.io blog walks through the same frame-sequence technique with GSAP instead of this engine.
scroll-hero/
ββ README.md # This file
ββ README.tr.md # Turkish translation
ββ HOW-TO-SCROLLING-VIDEO-HERO-BANNER.md # Full guide
ββ HOW-TO-SCROLLING-VIDEO-HERO-BANNER.tr.md # Full guide (Turkish)
ββ PROMPT.md # One-send AI prompt
ββ CHECKLIST.md # Pre-ship QA
ββ CHECKLIST.tr.md # Pre-ship QA (Turkish)
ββ LICENSE # MIT
ββ package.json # npm metadata / exports map
ββ demo/ # Demo GIF + full-quality recording
ββ src/
β ββ scroll-hero.js # Standalone engine (classic script)
β ββ scroll-hero.mjs # ESM entry for bundlers
β ββ scroll-hero.css # Scoped styles
β ββ scroll-hero.html # Markup snippet
ββ scripts/
β ββ extract-frames.ps1 # Windows frame extractor
β ββ extract-frames.sh # macOS/Linux frame extractor
ββ test/
β ββ smoke.mjs # Zero-dependency engine test
ββ .github/workflows/ci.yml # Runs the smoke test on push/PR
ββ example/
ββ index.html # Working demo
ββ frames/ # Drop generated frames here
<!-- 1. Link the CSS -->
<link rel="stylesheet" href="path/to/scroll-hero.css" />
<!-- 2. Add the banner markup -->
<div class="scroll-hero" data-scroll-hero data-frame-count="48" data-frame-dir="frames">
<div class="scroll-hero__sticky">
<canvas class="scroll-hero__canvas" aria-hidden="true"></canvas>
<picture class="scroll-hero__fallback">
<img src="frames/frame-001.webp" alt="" loading="eager" />
</picture>
<div class="scroll-hero__veil" aria-hidden="true"></div>
<div class="scroll-hero__content">
<h1>Headline</h1>
<p>Subtitle</p>
</div>
</div>
</div>
<!-- 3. Include the script -->
<script src="path/to/scroll-hero.js"></script>npm i scroll-hero(Not on npm yet β until it is published, use the CDN snippet below or copy src/ into
your project. Everything else in this section already works from a local path.)
import ScrollHero from 'scroll-hero'; // resolves to src/scroll-hero.mjs
import 'scroll-hero/css';
ScrollHero.initAll();src/scroll-hero.js is a classic script with no export, so importing that file
directly gives you undefined in a real ESM setup β use scroll-hero.mjs, which the
package's import condition already points at. There is no window on the server, so
the default export is undefined during SSR; guard your effect or import lazily.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/Remilya/scroll-hero@v1.0.0/src/scroll-hero.css" />
<script src="https://cdn.jsdelivr.net/gh/Remilya/scroll-hero@v1.0.0/src/scroll-hero.js"></script>Pin a tag as above rather than @main, so a later commit cannot change what your page loads.
Turn the auto-init off and own the lifecycle, or the DOMContentLoaded init and the MutationObserver will fight your framework:
window.ScrollHeroNoAutoInit = true;
window.ScrollHeroNoObserve = true;
// in the component
const instance = ScrollHero.create(ref.current); // null on the static path
return () => instance?.destroy();Full guidance β asset paths, static exports, Strict Mode, SSR β in the Framework Integration section of the HOW-TO.
See the full markup in src/scroll-hero.html β copy the div block, customise the data-* attributes, link the CSS and JS, done.
All via data-* attributes on the .scroll-hero element. The canonical reference β
every attribute, the engine's own attributes, and the JavaScript API β is the
Data-Attribute Reference
in the HOW-TO. The most-used ones:
| Attribute | Default | Description |
|---|---|---|
data-frame-count |
(required) | Total frames in sequence |
data-frame-dir |
frames |
Base frames directory (use an absolute path if the site has a base path) |
data-frame-dir-desktop / -mobile |
(=base) | Per-breakpoint overrides |
data-breakpoint |
720 |
Viewport width breakpoint (px) |
data-initial-batch |
6 |
Frames to preload on init |
data-scroll-length |
300 |
Sticky height in vh |
data-overlay-fade |
20 |
% scroll over which content fades |
data-version |
"" |
Cache-busting query param |
- Sticky + scroll progress: the banner occupies
Nvhof scroll space; a sticky inner container (100svh) stays fixed while the user scrolls through it. Progress is-top / (bannerHeight - stickyHeight)β0at rest,1when the sticky container releases β so it behaves the same at the top of the page or mid-page. - 1-based frames: progress
0drawsframe-001, progress1drawsframe-<count>, matching what the extractors write. - Progressive loading: the first
data-initial-batchframes load immediately, the rest follow in a background queue. Renders the nearest already-loaded frame so scrubbing never blocks. - Layering: the poster sits below the canvas and stays there; the canvas is revealed (
data-scroll-hero-ready) only once a frame has been drawn, so there is no blank flash and nothing ever covers the animation or the content. - Responsive: separate desktop/mobile frame directories with a configurable breakpoint; switching breakpoints reloads the sequence.
- Fallbacks: no canvas, no 2d context, too few frames β static poster;
prefers-reduced-motionorsaveDataβ poster only, zero frame downloads. Individual frame failures are skipped silently. - Multiple instances: every
[data-scroll-hero]element initialises independently, with its own listeners and throttles. - Off-screen pause: an
IntersectionObserverstops all measuring and drawing while the banner is out of view β nogetBoundingClientRect()per scroll event on a long page β and re-syncs the moment it comes back. - One poster download: if the
<img>in your markup already points at the poster file, the engine leaves itssrcalone instead of rewriting it with?v=, which would fetch the same image twice. Put the version query in the markup too when you bumpdata-version. - Clean teardown:
destroy()removes every listener, timer and observer, aborts in-flight image loads and clears the canvas. - Custom event:
scrollhero:progressfires on the element with{ progress: 0-1, frameIndex }whenever the frame index changes.
The engine writes --scroll-hero-progress (0β1) onto the banner element and fires
scrollhero:progress, so most "extra" effects need no engine changes and no new attributes.
Parallax layers β any element inside the banner can ride the same progress value:
.my-layer {
transform: translateY(calc(var(--scroll-hero-progress) * -60px));
}Come into focus / colour as the user scrolls:
.scroll-hero__canvas {
filter: grayscale(calc((1 - var(--scroll-hero-progress)) * 100%));
}Analytics milestones (GA4 / GTM) from the existing event:
const sent = new Set();
document.addEventListener('scrollhero:progress', (event) => {
const percent = Math.floor(event.detail.progress * 4) * 25; // 0 / 25 / 50 / 75 / 100
if (percent && !sent.has(percent)) {
sent.add(percent);
window.dataLayer?.push({ event: 'scroll_hero_progress', percent });
}
});- Canvas is
aria-hidden="true". - Veil is
aria-hidden="true". - Fallback
<picture>usesalt=""(decorative). prefers-reduced-motion: reducedisables the entire canvas engine β static poster only.navigator.connection.saveDataalso disables frame downloads.@media (scripting: none)gives the same static hero when JavaScript is unavailable.- Content heading should be a meaningful
<h1>β<h3>in document order.
- Any modern browser (Chrome, Firefox, Safari, Edge) β IE11 is not supported.
- ffmpeg + ffprobe in PATH (for frame extraction via the scripts).
bash,bcandawkforextract-frames.sh; PowerShell 5+ forextract-frames.ps1.- Node 18+ to run
test/smoke.mjs(no packages required).
MIT β see LICENSE. Free to use, modify, and distribute; keep the copyright notice. Copyright (c) 2026 Remilya.
