Canvas scroll-scrub frame-sequence player: sticky stage, drop-in contract (frames + config.js) and wiring guide
Scroll effect from Black-Line-Ops/scroll-scrub-hero.
From prompt to live site
- 01
Copy the instructions
Grab the instructions, with the licence notice appended.
- 02
Paste into your AI
Drop it into Claude, Cursor or v0 — or straight into your own project.
- 03
Launch it live
Adapt the copy, wire your data, and ship — with the notice kept alongside.
Read the full instructions
# Wiring the frames into a page
## Contents
- [The drop-in contract](#the-drop-in-contract)
- [Adding a hero to a page that has none](#adding-a-hero-to-a-page-that-has-none)
- [Gotchas that took real debugging](#gotchas-that-took-real-debugging)
## The drop-in contract
`build-frames.mjs` writes exactly this, and any page that already reads it needs no changes:
```
assets/hero-scroll/frames/
├── config.js
├── clip1/frame-0001.webp … frame-0040.webp
├── clip2/…
└── clipN/…
```
`config.js` sets up to four globals (the prefix is `--var`, default `HERO`):
```js
window.HERO_EXT="webp";
window.HERO_ASPECT="16:9"; /* whenever build-frames could read it off the footage */
window.HERO_ALPHA=true; /* --float builds only; absent otherwise */
window.HERO_SEQ=[
{"dir":"clip1","n":40,"k":"01 · Excavation","t":"We dig, frame, and reinforce for Florida soil."},
…
];
```
**`HERO_ALPHA` is not optional to handle.** `--float` frames are transparent wherever the background
used to be, and the paint loop below is a bare `drawImage` — correct and fast for opaque frames,
because each one completely covers the last. Composite a *transparent* frame over its predecessor
and nothing erases the old one: the subject drags a trail of every previous position behind it. The
engine below clears when the flag is set and skips the clear when it is not, so both kinds of build
paint correctly out of the box.
`HERO_ASPECT` is informational. This engine cover-fits into whatever box the CSS gives it, so it has
no use for the ratio — it is there for pages that size a non-full-bleed container themselves, and to
tell a landscape frame set from a portrait one when a build ships both.
`dir` is the folder, `n` the exact frame count, `k` a short kicker, `t` the caption. Frame files
are `frame-` plus a 4-digit 1-based index. `n` must match what's on disk exactly — the page
indexes by number, so a mismatch is a missing image mid-scrub.
Check before wiring anything new:
```bash
grep -n "HERO_SEQ\|hero-scroll/frames" <page>.html
```
## Adding a hero to a page that has none
Load the config before the engine, and GSAP + ScrollTrigger before both:
```html
<script src="assets/hero-scroll/frames/config.js"></script>
```
Markup — the tall scroller with a sticky viewport inside it:
```html
<section id="scrolly">
<div class="sticky">
<canvas id="heroCanvas"></canvas>
<div class="hlede" id="hlede">
<div class="hkick"><span id="capNum"></span> <span id="capStage"></span></div>
<h1 id="capT">Scroll to build <em>your backyard.</em></h1>
<p id="capSub"></p>
</div>
</div>
</section>
```
```css
#scrolly{position:relative; height:600vh} /* scroll distance = how slow the scrub feels */
#scrolly .sticky{position:sticky; top:0; height:100vh; height:100dvh; overflow:hidden}
#heroCanvas{position:absolute; inset:0; width:100%; height:100%; display:block}
```
`height:600vh` is the tuning knob: more height, slower scrub. Roughly 2vh per frame feels
natural — 250 frames wants ~500–600vh.
The engine:
```js
/* Wrapped in an IIFE so a problem in here can never take out the rest of the page's inline
script - on these single-file pages everything shares one scope, and one thrown error at
the top level silently kills every subsystem below it. */
(function () {
const SEQ = window.HERO_SEQ || [], EXT = window.HERO_EXT || 'webp';
/* Set by a --float build, where the frames carry alpha. It decides one thing - whether paint()
erases before it draws - and getting it wrong is invisible until the subject moves. */
const ALPHA = !!window.HERO_ALPHA;
/* Derive the frame path from where config.js actually loaded from, rather than assuming the
page sits at the site root. A hard-coded 'assets/...' resolves against the DOCUMENT, so the
same hero that works at / renders a blank canvas at /projects/thing/ with no error at all.
Override explicitly with window.HERO_BASE if your assets live somewhere unrelated. */
const BASE = window.HERO_BASE || (function () {
const s = [...document.querySelectorAll('script[src]')]
.find(x => /config\.js(\?|$)/.test(x.getAttribute('src') || ''));
return s ? new URL('.', new URL(s.src, location.href)).href : 'assets/hero-scroll/frames/';
})();
if (!SEQ.length) {
/* config.js missing or empty. Say so once and leave the page alone - a hero that doesn't
appear is survivable; a page whose scripts all died is not. */
console.warn('[hero] no HERO_SEQ found — is assets/hero-scroll/frames/config.js loaded before this script?');
return;
}
/* Flatten every clip into one list. HOLD0 repeats frame 1 so the intro headline has room
before the build starts moving - without it the first scroll pixel already changes the
image and the opening copy never gets read. */
const FLAT = [], HOLD0 = 37;
const F1 = `${BASE}${SEQ[0].dir}/frame-0001.${EXT}`;
for (let i = 0; i < HOLD0; i++) FLAT.push({ src: F1, ci: 0 });
SEQ.forEach((c, k) => { for (let j = 1; j <= c.n; j++)
FLAT.push({ src: `${BASE}${c.dir}/frame-${String(j).padStart(4,'0')}.${EXT}`, ci: k }); });
const FRAMES = FLAT.length;
const cv = document.getElementById('heroCanvas'), cx = cv.getContext('2d');
const dpr = Math.min(2, devicePixelRatio || 1);
let lastImg = null, curF = 0, targetF = 0, scrollProg = 0;
function fit () {
const r = cv.getBoundingClientRect();
cv.width = Math.max(1, Math.round(r.width * dpr));
cv.height = Math.max(1, Math.round(r.height * dpr));
cx.imageSmoothingQuality = 'high';
if (lastImg) paint(lastImg);
}
/* cover-fit: fill the canvas, crop the overflow */
function paint (img) {
if (!img || !img.complete || !img.naturalWidth) return;
lastImg = img;
/* Only on alpha frames, and the asymmetry is deliberate. An opaque frame covers every pixel of
the last one, so clearing first is a wasted full-canvas write on every scroll tick. A
TRANSPARENT frame covers nothing it does not draw, so without this the previous frame stays
underneath and the subject smears a trail of its own history across the hero. Nothing errors
either way, which is why the flag exists rather than a comment telling you to remember. */
if (ALPHA) cx.clearRect(0, 0, cv.width, cv.height);
const s = Math.max(cv.width / img.naturalWidth, cv.height / img.naturalHeight);
const w = img.naturalWidth * s, h = img.naturalHeight * s;
cx.drawImage(img, (cv.width - w) / 2, (cv.height - h) / 2, w, h);
}
const cache = new Map(), CACHE_MAX = 90;
function get (i) {
if (i < 0 || i >= FRAMES) return null;
let e = cache.get(i);
if (e) { cache.delete(i); cache.set(i, e); return e; } /* LRU touch */
const im = new Image();
e = { im, ready: false };
const ok = () => { e.ready = true; if (i === Math.round(curF)) paint(im); };
im.src = FLAT[i].src;
(im.decode ? im.decode() : Promise.reject())
.then(ok).catch(() => { im.complete ? ok() : (im.onload = ok, im.onerror = ok); });
cache.set(i, e);
if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
return e;
}
function drawFrame (i) {
const e = get(i);
if (e && e.ready) paint(e.im); else if (lastImg) paint(lastImg);
for (let k = 1; k <= 6; k++) get(i + k); /* read ahead */
}
function tick () {
curF += (targetF - curF) * 0.22; /* ease toward the target */
if (Math.abs(targetF - curF) < 0.02) curF = targetF;
drawFrame(Math.round(curF));
}
/* Preload a first batch, then start. Attaching load AND error unconditionally matters -
see the background-tab gotcha below. */
const INIT = Math.min(44, FRAMES);
let rdy = 0, started = false;
for (let i = 0; i < INIT; i++) {
const e = get(i); let counted = false;
const bump = () => { if (counted) return; counted = true; if (++rdy === INIT) start(); };
if (e.ready) bump();
else { e.im.addEventListener('load', bump, { once: true });
e.im.addEventListener('error', bump, { once: true });
if (e.im.complete) bump();
if (e.im.decode) e.im.decode().then(bump).catch(() => {}); }
}
setTimeout(() => { if (!started) start(); }, 12000); /* never strand the user */
function start () {
if (started) return; started = true;
fit(); addEventListener('resize', fit); drawFrame(0);
if (!window.gsap || !window.ScrollTrigger) { drawFrame(FRAMES - 1); return; }
gsap.registerPlugin(ScrollTrigger);
ScrollTrigger.create({
trigger: '#scrolly', start: 'top top', end: 'bottom bottom',
onUpdate: s => { scrollProg = s.progress; targetF = scrollProg * (FRAMES - 1); },
});
gsap.ticker.add(tick);
}
})();
```
Captions, if you want them, key off `FLAT[i].ci` — the clip index each frame belongs to — and
read `SEQ[ci].k` / `SEQ[ci].t`. Swap them when `ci` changes rather than every frame.
## Gotchas that took real debugging
**`img.decode()` never settles in a background tab.** It neither resolves nor rejects, so a
`.catch()` fallback never fires either and the loader hangs forever on a backgrounded load.
Attach `load` and `error` listeners *unconditionally* and let them race `decode()`, with a
one-shot guard so the count stays honest. This is why the preload loop above looks redundant.
**Name collisions in a single shared script scope.** These pages tend to be one long inline
script. A second `function paint(...)` anywhere in the file silently replaces this one and the
canvas renders blank while every other check still passes. Grep for any name you introduce, and
when verifying, assert the canvas has *painted pixels* rather than that the element exists:
```js
const c = document.querySelector('#scrolly canvas'), g = c.getContext('2d');
const d = g.getImageData(0, 0, c.width, c.height).data;
const seen = new Set(); for (let i = 0; i < d.length; i += 4 * 997) seen.add(d[i]+','+d[i+1]+','+d[i+2]);
seen.size > 20; // a blank canvas samples 1-2 colours
```
**Temporal dead zone on a warm cache.** If frames are already cached, their decode callbacks run
*synchronously* inside the preload loop. Anything they touch that's declared with `let`/`const`
*below* the loop throws a ReferenceError, which aborts the loop and hangs the page — and only on
a warm cache, so it looks intermittent. Declare all loader state above the loop.
**`overflow-x: hidden` on `html`/`body` silently kills `position: sticky`.** The scrub simply
never pins, while `getComputedStyle` still reports `sticky`. Fix the overflow at whatever element
actually needs it (usually `min-width: 0` on a flex child, or parking an off-canvas nav
off-screen left) rather than clipping the root.
**CSS source order at equal specificity.** Long single-`<style>` pages accumulate duplicate
selectors. Before adding a rule, grep for other declarations of the same selector — and remember
media queries add no specificity, so a later top-level duplicate beats a rule inside an earlier
`@media`.
---
Licence notice — keep this with anything built from the instructions above:
MIT License
Copyright (c) 2026 Black Line Ops
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Source and licence
Source: Black-Line-Ops/scroll-scrub-hero@a390a0486cae
Author: 2026 Black Line Ops
Entry: references/hero-wiring.md
- code lives as fenced blocks inside a markdown reference (HTML, CSS and a ~115-line JS engine), not as runnable files; needs extracting
- also part of the scroll-scrub-hero-skill item (same file)
MIT License Copyright (c) 2026 Black Line Ops Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Files (1)
- hero-wiring.md (11,162 bytes)
Want the whole library?
Every item's prompt, code and instructions, with a CreativePower subscription.