A canvas timeline driven by scroll position: a pinned stage, reels that are pure functions of progress, and captions the drawing is never allowed to touch. This method runs the story drum on the ImuTechz home page, the R&D drum and the Founder path film. It also powers the five-reel demo in assets/demo.html.
The whole technique fits in one sentence. Scroll position is the only clock: map it to p ∈ [0, 1], and make every pixel, caption and rotation a function of p.
What good looks like
- It scrubs like a video. Scroll forward and the film plays. Scroll back and it rewinds, frame for frame. Stopping halfway leaves a frame that makes sense.
- The page is never hijacked. Wheel, trackpad, touch and keys move the page exactly as they do everywhere else. The section is just tall.
- The words are always readable. Captions are real DOM text on a card. The drawing lays itself out in the space the captions leave, and the canvas is clipped around them as a safety net.
- It is a control, not just a show. A story drum's faces are links. Its rail, arrow buttons, arrow keys and mouse drag all seek by scrolling the page, so the scroll and the drum can never disagree.
- An idle page costs nothing. No requestAnimationFrame runs once p settles or after the section leaves the viewport.
- Reduced motion is respected. No smoothing, no in-between drum roll and no caption entrance animation. All of the content is still there.
- It holds up from 360 to 1920 px wide, in light and dark, at DPR 1 and 2.
Hard rules
- p comes from the scroll position, never from a clock. Do not use
performance.now(),Date, CSS animations orsetIntervalinside a reel. Smoothing (a lerp toward the target p) is allowed because it converges to the same frame. - A reel is a pure function
draw(ctx, t, env). It keeps no state between frames and never callsMath.random(); use a seededhash(i)instead. The same (t, env) must paint the same pixels. - Pin with CSS, not JS. Use a tall track (
position: relative; height: N×vh) holding aposition: sticky; top: 0; height: 100svhstage. Never setposition: fixedon the stage, never lock the body, and never putoverflow: hidden|auto|clipon an ancestor of the track. - Never capture input. Use no
preventDefaulton wheel or touch, nooverflow: hiddenon html or body, and no scroll snapping on the track. Only a focused drum may handle arrow keys, and it does so by scrolling the page. - Seek by scrolling. Every button, rail item, key and anchor changes
window.scrollY. Never set p or a rotation directly. Usebehavior: "instant"for programmatic jumps that must not animate, because"auto"obeys a page's CSSscroll-behavior: smooth. - Text lives in the DOM. Captions, titles and links are HTML. Canvas text is limited to decorative labels such as years, axis ticks and step names.
- Measure captions every frame and keep the drawing off them. Each caption and HUD element is a keep-out (
data-keep). Reels lay out insideenv.free, and the engine clips the keep-outs with an even-odd clip. - Never show two captions at once. Caption windows sit inside their reel with a gap at each boundary, and reels crossfade inside that gap.
- The loop stops when idle. Start rAF on scroll or resize only while the section is near the viewport (IntersectionObserver), and stop it when p equals the target.
- Canvas is sized to the stage at
min(devicePixelRatio, 2). CallsetTransform(dpr, …)once per frame and clear it every frame. - Colours come from CSS custom properties. Do not hard-code a light-mode palette into a reel. Re-read the palette on
prefers-color-schemechange. - Honour
prefers-reduced-motion: reduce. Use no smoothing, have the drum snap face to face, and turn off caption entrance animations. Frames still follow the scroll, because the user drives them.
Build order
Do it in this order. Each step is testable before the next.
- Content first. Write the chapters: a kicker, a title, one or two sentences and an optional link for each. Choose the number of reels (3 to 7) and give each one roughly 80 to 100 svh of scroll.
- Track and stage. Add the tall section with a sticky 100svh stage inside it, and check that it pins with no JS at all (see layout-and-captions).
- The scrub engine. Wire
ScrollMotion.scruborfilm(see engine) and log p. Check that it reads 0 at pin start and 1 at pin end, and that it moves both ways. - Captions and HUD in the DOM. Place them per breakpoint, mark them
data-keep, and let the engine drive their opacity from p. - Reels, one at a time. Write
draw(ctx, t, env)insideenv.free(see drawing), and scrub it back and forth before writing the next one. - Motion grammar. Add enter, hold and exit envelopes, staggers, dwell and crossfades (see motion-grammar).
- Interactivity, if it is a drum. Add face links, the rail, prev and next buttons, keys and drag, all seeking by scroll.
- Performance and accessibility pass. Cover the idle loop, DPR, reduced motion, focus order, screen reader text and dark mode (see performance-and-a11y).
- Verify at 360, 390, 414, 768, 1024, 1280, 1440 and 1920 with the headless script (see verification). Do not call it done until every check passes.
Decision checklists
Canvas film or DOM/CSS transforms?
- Many small moving parts, lines that draw, particles, or charts → canvas film (
ScrollMotion.film). - A handful of cards, images or text panels → DOM transforms driven by
scrub(the drum is one). - Real 3D meshes or shaders → this skill is not enough. Use WebGL, but keep rules 1 to 5 and 9 to 12.
Pin or view mode?
- The section should hold the screen while its story plays →
mode: "pin"(tall track and sticky stage). - The effect rides along as content passes, as with marquees or parallax →
mode: "view", with no pinning.
How tall is the track?
- Use
n × 80–100svh + 100svhfor a film andn × 70vh + 100vhfor a drum. If a test reader says it goes "too fast", add height. Never slow it down by capturing the wheel.
Where do captions go?
- Desktop (≥ 760 px): beside the drawing (left or right card) or as a wide bottom card. Phones: always a bottom sheet, with the drawing above it.
- With a fixed site header, set
--sm-nav-hso the HUD and drum clear it.
Does it need the drum?
- Use the drum when the chapters are things a user may want to go to (pages, products, sections). Otherwise use the film alone.
Failure modes and fixes
| Symptom | Cause | Fix |
|---|---|---|
| Stage scrolls away instead of pinning | An ancestor has overflow: hidden/auto/clip, or the stage has no top |
Remove the overflow (use overflow-x: clip on body if needed) and set top: 0 |
| Animation only plays forwards, or jumps on scroll-up | State accumulated across frames, or a clock or Math.random in a reel |
Make the reel a pure function of t; use hash(i) for randomness |
| Drawing runs under the caption | Reel laid out on the full stage | Lay out in env.free; keep data-keep on the caption; let the engine clip |
| Caption box measured in the wrong place mid-fade | Used getBoundingClientRect (includes the transform) |
Measure layout boxes (offsetLeft/Top/Width/Height, see boxIn) |
| Two captions overlap at a boundary | Caption windows touch | Fade captions out at 1 − cross and in at cross; reels crossfade in the gap |
| Fans spin on an idle page | rAF loop never stops | Stop when cur === target; gate on IntersectionObserver |
| Blurry canvas on phones / slow on 4K | Canvas at CSS size, or uncapped DPR | Use width = W × min(dpr, 2) and setTransform(dpr, 0, 0, dpr, 0, 0) |
| Drum and page disagree after clicking the rail | The control set the rotation directly | Every control calls scrollToProgress; rotation only ever comes from p |
| Drag jitters, then snaps back | Page has scroll-behavior: smooth, so scrollTo(x, y) animates |
Use scrollTo({ top, behavior: "instant" }) while dragging |
| Clicking a face after a drag opens its link | The click that ends a drag fires | Swallow one capture-phase click after a drag that moved > 4 px |
| Mobile 100vh jump when the URL bar hides | vh on the stage |
Use 100svh for the stage; compute p with the stage's own height |
| Dark mode shows a light-mode drawing | Hard-coded colours | Read --sm-* tokens via readPalette; re-read on scheme change |
| Back faces of the drum show through | Missing backface-visibility: hidden |
Set it, with the -webkit- prefix, on every face |
| Drum face thumbnails drawn at the wrong size | Used the projected rect of a 3D-transformed face | Size by clientWidth/clientHeight |
Files
- references/engine.md: p, the scrub loop,
film(), seeking, the React hook. - references/drawing.md: the reel contract, canvas setup, routes, pop-ins, particles and palettes.
- references/layout-and-captions.md: track and stage CSS, keep-outs, the free rect, the clip, the drum layout.
- references/motion-grammar.md: envelopes, staggers, dwell, crossfades, pacing and easing.
- references/performance-and-a11y.md: the idle loop, layout cost, reduced motion, keyboard, focus and screen readers.
- references/verification.md: the eight widths, the headless Brave/Chrome script and the manual pass.
- assets/demo.html: standalone, zero dependencies, double-click to run. Five reels and the story drum.
- assets/scroll-motion.js: the engine (
scrub,film,drum,scrollToProgressand helpers), UMD. - assets/reels.js: five example reels (orbit, path, stack, wave, close) and
still()for thumbnails. - assets/scroll-motion.css: film and drum styles, light and dark tokens.
- assets/ScrollFilm.jsx: the React wrapper for the film, plus the
useScrubhook. - assets/StoryDrum.jsx: the React story drum, plus a self-rolling
DrumPreview. assets/preview-orbit.jpg,assets/preview-path.jpg,assets/preview-close.jpg: stills of the demo at 1440 × 900.
Start from the demo: open it, view source and replace the content. The wiring at the bottom of the file is about 60 lines.
Reference files
The six documents SKILL.md points at. They are also downloadable as raw markdown below.
references/engine.md11 KB
Engine: from scroll position to p
Everything in this skill hangs off one number: p ∈ [0, 1], how far the reader has scrolled through a section. The engine measures it, optionally smooths it, and calls your frame function only while it changes. It never touches input.
The full source is assets/scroll-motion.js. This document explains the parts you must get right if you write your own.
The two modes
mode "pin" mode "view"
┌──────────── track (tall) ───┐ viewport
│ ┌── stage (sticky, 100svh) ┐│ p = 0 ┌──────────┐
│ │ ││ ← track │ │ ← el's top enters
│ │ canvas + captions ││ top at │ │ at the bottom: p = 0
│ │ ││ viewport └──────────┘
│ └──────────────────────────┘│ top ...
│ ... │ el's bottom leaves
│ │ p = 1 at the top: p = 1
└─────────────────────────────┘ ← track bottom at viewport bottomfunction progressOf(el, mode, stage) {
const r = el.getBoundingClientRect();
const vh = window.innerHeight || 1;
if (mode === "pin") {
// the scrollable run is the track minus the stage it pins
const span = el.offsetHeight - (stage ? stage.offsetHeight : vh);
return span > 4 ? clamp01(-r.top / span) : clamp01(-r.top / vh);
}
return clamp01((vh - r.top) / (vh + r.height));
}Use the stage's own height (not innerHeight) as the pinned part when the stage is 100svh. On mobile, innerHeight grows when the URL bar hides, but svh does not, and mixing the two makes p overshoot.
The scrub loop
function scrub(el, onFrame, { mode = "pin", smooth = 0.18, stage = null } = {}) {
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
let target = 0, cur = -1, raf = 0, on = false;
const measure = () => { target = progressOf(el, mode, stage); };
function tick() {
raf = 0;
if (!on) return;
measure();
const d = target - cur;
// snap when close (so the loop can end), when reduced, or on the first frame
cur = cur < 0 || reduce || Math.abs(d) < 0.0005 ? target : cur + d * smooth;
onFrame(cur);
if (cur !== target) raf = requestAnimationFrame(tick); // settled → loop ends
}
const kick = () => { if (on && !raf) raf = requestAnimationFrame(tick); };
// only run near the viewport
const io = new IntersectionObserver((es) => {
const vis = es[es.length - 1].isIntersecting;
if (vis && !on) { on = true; kick(); }
else if (!vis && on) { on = false; cancelAnimationFrame(raf); raf = 0; }
}, { rootMargin: "25% 0px" });
io.observe(el);
addEventListener("scroll", kick, { passive: true }); // passive: never blocks scrolling
addEventListener("resize", kick);
measure(); cur = target; onFrame(cur); // paint the right frame on load
return { stop() { on = false; cancelAnimationFrame(raf); io.disconnect();
removeEventListener("scroll", kick); removeEventListener("resize", kick); } };
}Why it is shaped like this:
- Scroll events only kick the loop. They never draw. The rAF tick reads layout once and writes once.
- The snap threshold (
0.0005) is what letscur === targetbecome true, so the loop ends. Without it, a lerp approaches forever and the page never idles. - Smoothing is a lerp toward the scroll target, not a timeline. Because it converges to the same p, the frame you land on is the same frame you would get without smoothing. That is why "pure function of p" survives it. Use
smooth0.12 to 0.2. Higher is snappier, and 1 means none. - On the first frame
cur = target, so a page reloaded mid-section shows the correct frame instead of animating from 0. - IntersectionObserver with a margin means a page with five scroll sections runs, at most, the loop of the one on screen.
The film: reels on a shared timeline
ScrollMotion.film() is the scrub loop plus a canvas, a timeline split into equal reel slices, captions and keep-outs.
const player = ScrollMotion.film({
track: document.querySelector(".smf"), // tall section
stage: document.querySelector(".smf-stage"), // sticky child
canvas: document.querySelector(".smf-canvas"),
reels: ScrollReels.all, // [{ name, draw(ctx, t, env) }]
captions: [...document.querySelectorAll(".smf-cap")], // one per reel, in order
smooth: 0.14, // lerp factor
cross: 0.12, // reel crossfade half-width, in reel units
capFade: 0.1, // caption fade length, in reel units
onChange(i, reel) { /* the dominant reel changed: analytics, aria-live, etc. */ },
});The timeline math
With n reels, reel i owns p ∈ [i/n, (i+1)/n]. Inside it, the local coordinate is u = p·n − i (unclamped) and the reel receives t = clamp01(u).
const local = (p, i) => p * n - i;
function reelAlpha(p, i) { // reels crossfade across each boundary
const u = local(p, i);
const fin = i === 0 ? 1 : smoothstep((u + cross) / (2 * cross));
const fout = i === n - 1 ? 1 : smoothstep((1 + cross - u) / (2 * cross));
return Math.min(fin, fout);
}
function capAlpha(p, i) { // captions fade inside their reel, leaving a gap
const u = local(p, i);
const fin = i === 0 ? 1 : seg(u, cross, cross + capFade);
const fout = i === n - 1 ? 1 : 1 - seg(u, 1 - cross - capFade, 1 - cross);
return Math.min(fin, fout);
}The first reel never fades in and the last never fades out, so the film opens and ends on a full frame. Captions leave a gap of 2 × cross around each boundary, which is exactly where the reels crossfade. Two captions are therefore never visible together, and the keep-outs never jump while a reel is drawing.
The frame
Each painted frame does three things, in this order:
function paint(p) {
// 1. WRITE: caption opacity/transform, HUD counter and bar, all from p
// 2. READ: measure every keep-out (captions + [data-keep]) → rects
// 3. DRAW: clear, clip out the keep-outs (evenodd), draw each reel whose alpha > 0
// with ctx.globalAlpha = alpha and env.free = freeRect(stage − keep-outs)
}Writes happen before reads, so there is at most one layout per frame. The keep-outs are measured with offsetLeft/Top/Width/Height walked up to the stage (boxIn). These ignore transforms, so a caption that is mid-slide still reserves its resting box. See layout-and-captions.
The returned handle
| Member | Use |
|---|---|
p |
the current (smoothed) progress |
keeps |
the keep-out rects of the last painted frame (tests use this) |
renderAt(p) |
paint any p without scrolling: posters, tests and purity checks |
seek(p) / seekReel(i, at) |
scroll the page to a p, or to a point inside reel i |
retheme() |
re-read the palette and repaint (after a manual theme toggle) |
destroy() |
remove every listener and observer |
Seeking: always by scrolling
function scrollToProgress(el, p, { stage = null, smooth = !prefersReduced(), nudge = 0 } = {}) {
const top = el.getBoundingClientRect().top + window.scrollY;
const run = el.offsetHeight - (stage ? stage.offsetHeight : window.innerHeight);
window.scrollTo({ top: Math.round(top + run * clamp01(p)) + nudge,
behavior: smooth ? "smooth" : "instant" });
}- Every control (drum rail, prev and next, keys, a "jump to chapter" link, drag release) calls this. The scroll position stays the single source of truth, so the scrollbar, browser history (
#anchor), the drum and the film all agree. - Use
"instant", never"auto", for a non-animated jump."auto"means "whatever CSS says", and many sites sethtml { scroll-behavior: smooth }. - A
nudgeof 1 px past an exact chapter boundary avoids landing a hair before it on fractional scroll positions. - Plain anchors work too. The demo puts zero-size anchors inside the track at
top = (at / n) × span(recomputed on resize), so<a href="#reel-path">seeks the film with no JS on the link.
The drum engine
ScrollMotion.drum(root) is scrub plus a rotation:
const f = clamp01(p) * (n - 1); // which face, fractional
const base = Math.floor(f), t = f - base;
const turn = reduce ? Math.round(f) // reduced motion: snap face to face
: base + t * t * (3 - 2 * t); // dwell: smoothstep inside each step
ring.style.transform = `translateZ(calc(var(--r) * -1)) rotateX(${turn * 360 / n}deg)`;
const idx = Math.min(n - 1, Math.round(f)); // the face you seeThe smoothstep inside each step makes the drum park on a face for a while, then roll quickly to the next. That is what makes it readable. A linear rotation is always half-way between two faces.
React
assets/ScrollFilm.jsx exports useScrub and ScrollFilm. assets/StoryDrum.jsx is the drum.
export function useScrub(ref, onFrame, { mode = "pin", smooth = 0.18, stageRef = null } = {}) {
const cb = useRef(onFrame);
cb.current = onFrame; // latest closure, without restarting the loop
useEffect(() => {
const s = ScrollMotion.scrub(ref.current, (p) => cb.current(p),
{ mode, smooth, stage: stageRef?.current ?? null });
return () => s.stop();
}, [ref, mode, smooth, stageRef]);
}React rules:
- Never
setStateper frame. Write styles through refs insideonFrame, and set state only when a discrete index changes (the face or reel). reelsmust be referentially stable (module scope oruseMemo), or the film is rebuilt.- In Next.js these are client components (
"use client"). The engine is safe to import during SSR, because nothing runs until an effect does. scroll-motion.jsis UMD. Webpack and Next hand youmodule.exports. Vite serves it as ESM with no exports, so it registersglobalThis.ScrollMotion.ScrollFilm.jsxresolves either. ImportScrollFilm.jsxbeforereels.js.
Writing your own engine instead
If you port this to another framework, keep these invariants and you have kept the method:
- One measured target per frame, from
getBoundingClientRectof the track. - A lerp toward it with a snap threshold, so it can settle.
- rAF scheduled only by scroll, resize or visibility, and only while not settled.
- Passive listeners, and no
preventDefaultanywhere near scrolling. - Every control seeks with
scrollTo.
references/drawing.md8.1 KB
Drawing: reels that are pure functions of t
A reel is an object with a name and a draw function:
const myReel = {
name: "path",
draw(ctx, t, env) {
// t ∈ [0, 1] local progress through this reel
// env = { W, H, dpr, mobile, colors, keep, free, alpha, p, index, reduce }
},
};The engine calls draw once per painted frame for every reel whose crossfade alpha is above zero. Before it does, it has already called setTransform(dpr, …), cleared the canvas, clipped out the keep-outs and set ctx.globalAlpha = env.alpha inside a save/restore.
The contract
| Do | Do not |
|---|---|
Derive every position, size, colour and alpha from t and env |
Keep variables that carry over between frames |
Use hash(i, seed) for variety |
Call Math.random() |
Lay out inside env.free |
Lay out on 0..W, 0..H |
Read colours from env.colors |
Hard-code #fff or #000 for content |
Multiply alpha (rgba(c, a)) |
Assign ctx.globalAlpha = … (it overwrites the crossfade) |
| Draw decorative labels (years, steps, ticks) | Draw sentences: those are DOM captions |
Return early if env.free is too small |
Throw, or draw outside it "just a bit" |
The test of purity is mechanical: renderAt(0.3), then renderAt(0.9), then renderAt(0.3) must produce identical pixels. verification checks this at every width.
Precompute nothing that depends on t. It is fine to cache geometry that depends only on env.free and the content (for example, route samples) as long as you rebuild it when free changes. In practice a 7-node route takes microseconds to sample, so the reference reels simply recompute it each frame.
env
| Field | Meaning |
|---|---|
W, H |
stage size in CSS px (the canvas is W × dpr by H × dpr) |
free |
{x, y, w, h}: the largest rectangle missing every keep-out, already inset by a margin. Lay out here. |
keep |
the keep-out rects (this reel's caption, the HUD, any visible caption), for blocked(keep, x, y, r) checks |
mobile |
W < 760: use thinner lines, fewer items and smaller labels |
colors |
{ink, dim, bg, surface, accent, accent2, cool} as [r, g, b], read from CSS tokens |
alpha |
this reel's crossfade alpha (already applied to globalAlpha) |
p, index |
global progress and reel index, for readouts |
reduce |
prefers-reduced-motion. Frames still follow scroll, but drop decorative wobble |
Canvas setup (the engine does this, and so must you if you roll your own)
function layout() {
const r = stage.getBoundingClientRect();
W = r.width; H = r.height;
dpr = Math.min(window.devicePixelRatio || 1, 2); // cap: 3× phones and 4K gain nothing visible
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
canvas.style.width = W + "px"; canvas.style.height = H + "px";
}
// per frame
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS px
ctx.clearRect(0, 0, W, H);Re-run layout from a ResizeObserver on the stage, not a window resize. Then repaint immediately at the current p, because resizing clears the canvas.
Helpers (assets/scroll-motion.js)
const { clamp01, lerp, seg, smoothstep, ease, stagger, hash, rgba, mix, route, strokeTo } = ScrollMotion;
seg(t, 0.2, 0.6) // 0 before 0.2, 1 after 0.6, linear between: a window inside the reel
ease.outCubic(x) // land, don't stop
ease.outBack(x) // pop with a small overshoot (nodes, dots)
ease.inOutCubic(x) // travel between two places
stagger(t, i, n, 0.4) // item i's own 0..1, each item taking 40% of the reel, starts spread evenly
hash(i, seed) // deterministic 0..1
rgba([r, g, b], a) // canvas colour string
mix(c1, c2, 0.3) // blend two palette coloursPattern: a route that draws with the scroll
This is the PathFilm technique. Nodes go through a Catmull-Rom spline sampled by arc length, so the line grows at a constant speed regardless of curvature.
draw(ctx, t, env) {
const { free: F, colors: C } = env;
const nodes = STEPS.map((_, i, a) => {
const u = i / (a.length - 1);
return [F.x + F.w * (0.04 + u * 0.92), F.y + 30 + (F.h - 74) * (0.5 + Math.sin(u * Math.PI * 2.4 + 0.4) * 0.5)];
});
const { samples, at, total } = route(nodes); // samples: [x, y, cumulativeLength]
const L = seg(t, 0.04, 0.92) * total; // lead-in and a hold at the end
// 1. the road ahead: faint, dotted, full length
ctx.setLineDash([2, 9]); ctx.strokeStyle = rgba(C.ink, 0.18); ctx.lineWidth = 2;
ctx.beginPath(); samples.forEach(([x, y], k) => (k ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); ctx.stroke();
ctx.setLineDash([]);
// 2. the road travelled: solid, gradient, exactly L long; strokeTo returns the head
ctx.strokeStyle = rgba(C.accent2); ctx.lineWidth = env.mobile ? 3 : 4;
const head = L > 0 ? strokeTo(ctx, samples, L) : samples[0];
// 3. milestones pop as the head reaches them
nodes.forEach(([x, y], i) => {
const reach = clamp01((L - at[i] + total * 0.012) / (total * 0.04));
const r = 11 * ease.outBack(reach);
/* reach === 0: hollow ring; reach > 0: halo + filled dot of radius r */
});
// 4. a glow at the head (no pulse: a time-based pulse would break purity)
}Choose the layout from the free rect's aspect: a horizontal sine when F.w > F.h, and a vertical zigzag on phones.
Pattern: staggered items that land
const build = seg(t, 0, 0.72); // the build uses the first 72% of the reel
for (let i = 0; i < n; i++) {
const k = ease.outCubic(stagger(build, i, n, 0.32));
const h = maxH * value[i] * k;
/* draw bar i with height h */
}
const line = seg(t, 0.62, 0.96); // the next layer waits for the buildOverlapping windows (span > 1/n) look fluid. Disjoint windows look mechanical.
Pattern: particles that resolve onto a mark
for (let i = 0; i < M; i++) {
const [sx, sy] = [F.x + hash(i, 1) * F.w, F.y + hash(i, 2) * F.h]; // scattered start (stable)
const [tx, ty] = target(i); // a point on the mark
const e = ease.inOutCubic(stagger(seg(t, 0, 0.8), i % 40, 40, 0.6));
const bend = Math.sin(e * Math.PI) * 40 * (hash(i, 5) - 0.5); // curved, not straight, paths
const x = lerp(sx, tx, e) + bend, y = lerp(sy, ty, e) - bend;
/* dot at (x, y): dim while travelling, solid once e > 0.98 */
}Keep the particle count modest (100 to 200) and draw plain arc fills. Do not use shadowBlur per particle.
Pattern: rhythm without a clock
A wave's phase comes from t (phase = t × 2π × 1.5), so scrolling is the oscillator. Inset rows by the largest amplitude so no crest leaves free:
const ampMax = (F.h / rows) * 1.1;
const rowY = (r) => F.y + ampMax + (F.h - 2 * ampMax) * (r / (rows - 1));Palette
// CSS (scroll-motion.css): tokens on :root, redefined for dark mode
// JS: env.colors comes from readPalette(stage)
ctx.strokeStyle = rgba(C.ink, 0.18); // structure
ctx.fillStyle = rgba(C.accent2); // the thing that matters
ctx.fillStyle = rgba(C.surface); // "holes" in dots (not #fff, which glows in dark mode)Two accents and an ink at several alphas carry every reel in the demo. Glows are radial gradients from accent at 0.35 to 0.55 alpha down to 0.
Cost guide
createLinearGradientandcreateRadialGradientper frame are fine (a few per reel).shadowBlur,filterandgetImageDatain the frame loop are not.- Thousands of path segments are fine. Thousands of
fill()calls on a 4K canvas are not. Batch dots into one path if you need more than a few hundred. - Text: set
ctx.fontonce per batch, and use a monospace stack for labels so widths stay predictable.
Thumbnails and posters
The same reel draws a still for a drum face or a poster: call it at a fixed t with an empty keep and a padded free. Size by clientWidth/clientHeight, not getBoundingClientRect, because a 3D-transformed face reports its projected box.
ScrollReels.still(faceCanvas, ScrollReels.path, 0.86, ScrollMotion.readPalette(drumEl), 12);references/layout-and-captions.md10.0 KB
Layout and captions
The drawing and the words share one screen. The rule is simple: the words win. Captions are DOM, measured every frame, and the canvas lays out in whatever space they leave. Anything that would still touch them is clipped away.
The track and the stage
<section class="smf" style="--smf-n: 5" aria-label="A film in five reels">
<div class="smf-stage">
<canvas class="smf-canvas" aria-hidden="true"></canvas>
<div class="smf-frame">
<div class="smf-hud" data-keep>…counter, progress bar…</div>
<article class="smf-cap" data-place="left">…</article> <!-- one per reel -->
…
<p class="smf-hint" data-keep>Scroll back: it rewinds</p>
</div>
</div>
</section>.smf { position: relative; height: calc(var(--smf-n) * 90svh + 100svh); }
.smf-stage { position: sticky; top: 0; height: 100svh; overflow: hidden; }
.smf-canvas{ position: absolute; inset: 0; display: block; pointer-events: none; }
.smf-frame { position: absolute; inset: calc(var(--sm-nav-h) + 16px) clamp(16px, 4vw, 48px) 20px; pointer-events: none; }
.smf-frame > * { pointer-events: auto; }- The track is the timeline. Its height sets the pace (see motion-grammar).
- The stage is sticky, so it pins with zero JS.
overflow: hiddengoes on the stage, never on an ancestor of the track. - The frame is the safe area: side gutters (16 px minimum on phones) and a top inset for a fixed site header (
--sm-nav-h). - The canvas has
pointer-events: none, so links in captions stay clickable and text stays selectable.
Sticky checklist when "it doesn't pin":
- Is any ancestor
overflow: hidden | auto | scroll | clip? Remove it, or useoverflow-x: cliponly onbody(clip does not create a scroll container in modern engines, but test it). - Does the stage have
top: 0? - Is the track taller than the stage? Sticky needs room to travel.
- Is the stage a flex or grid child stretched to the track's height? Give it an explicit
height: 100svh.
Keep-outs: measured every frame
Every element the drawing must avoid carries data-keep. Captions are keep-outs too, and the engine adds them per reel.
function boxIn(el, stage) { // layout box relative to the stage, transform-free
let x = 0, y = 0, n = el;
while (n && n !== stage) { x += n.offsetLeft; y += n.offsetTop; n = n.offsetParent; }
return { x, y, w: el.offsetWidth, h: el.offsetHeight };
}Why offset* rather than getBoundingClientRect:
- A caption mid-entrance has
translateY(14px). Its resting box is what should be reserved, otherwise the free rect wobbles while it slides. offset*returns layout values that the browser has already computed. After the frame's style writes, reading them costs one layout at most, and nothing when nothing changed.- The stage is
position: sticky, which makes it anoffsetParent, so the walk stops there.
Elements with display: none (for example a hint hidden on phones) have offsetParent === null and are skipped.
Each rect is padded (default 14 px) before use.
The free rect
freeRect(W, H, keeps, margin) returns the largest rectangle of the stage that misses every keep-out. It is greedy: for each keep-out (largest first) that intersects the current free area, take the best of the four sides of it.
function freeRect(W, H, keeps, margin = 0) {
let F = { x: 0, y: 0, w: W, h: H };
// area, with thin slivers penalised: a 1400×60 strip is not a good canvas
const score = (r) => r.w <= 0 || r.h <= 0 ? -1
: r.w * r.h * Math.pow(Math.min(r.w, r.h) / Math.max(r.w, r.h), 0.35);
for (const k of [...keeps].sort((a, b) => b.w * b.h - a.w * a.h)) {
if (!hits(F, k)) continue;
const cands = [
{ x: F.x, y: F.y, w: k.x - F.x, h: F.h }, // left
{ x: k.x + k.w, y: F.y, w: F.x + F.w - k.x - k.w, h: F.h }, // right
{ x: F.x, y: F.y, w: F.w, h: k.y - F.y }, // above
{ x: F.x, y: k.y + k.h, w: F.w, h: F.y + F.h - k.y - k.h }, // below
];
F = cands.reduce((best, c) => (score(c) > score(best) ? c : best));
}
return { x: F.x + margin, y: F.y + margin, w: F.w - 2 * margin, h: F.h - 2 * margin };
}What it gives you in practice:
| Layout | Free rect |
|---|---|
| HUD strip on top, caption card at the left middle | right of the card, below the HUD |
| Wide caption at the bottom (desktop) | above the caption, full width |
| Caption at the right | left of it |
| Phone: HUD on top, caption as a bottom sheet | the band between them |
Reels receive this as env.free and lay out inside it. The engine passes each reel a keep list of its own caption (visible or not), the fixed keep-outs and any caption currently visible. A reel therefore lays out around the caption it will share the screen with, and does so from its first faded-in frame, so nothing jumps when its caption arrives.
The clip: a safety net
Layout in free is the design. The clip is the guarantee:
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, W, H); // the whole stage…
for (const k of visibleKeeps) ctx.rect(k.x, k.y, k.w, k.h); // …minus every visible keep-out
ctx.clip("evenodd");
/* draw reels */
ctx.restore();Even-odd fill makes each inner rect a hole. A glow radius or label that spills past free is cut at the caption's padded edge instead of running under the text. If you ever see a visibly cut drawing, the reel's layout is wrong. Fix the layout, not the clip.
The verification script reads the canvas pixels inside every visible keep-out and requires an alpha of exactly 0.
Captions
<article class="smf-cap" data-place="left" data-keep>
<p class="smf-cap-k"><span>01 · Orbit</span><span>Pin a stage</span></p>
<h3 class="smf-cap-h">One timeline, many moving parts.</h3>
<p class="smf-cap-d">One or two sentences. No more.</p>
</article>.smf-cap { position: absolute; width: min(400px, 100%); padding: 22px 24px 24px; border-radius: 20px;
background: var(--sm-card); border: 1px solid var(--sm-card-line);
backdrop-filter: blur(14px) saturate(1.4); box-shadow: 0 18px 50px -26px var(--sm-shadow);
opacity: 0; visibility: hidden; }
.smf-cap[data-place="left"] { left: 0; top: 50%; margin-top: -110px; }
.smf-cap[data-place="right"] { right: 0; top: 50%; margin-top: -110px; }
.smf-cap[data-place="bottom"] { left: 0; bottom: 0; width: min(560px, 100%); }
.smf-cap[data-place="top"] { left: 0; top: 44px; }
@media (max-width: 759px) { /* phones: always a bottom sheet */
.smf-cap, .smf-cap[data-place] { left: 0; right: 0; top: auto; bottom: 0; width: auto; margin: 0; }
}- The engine writes
opacity,transformandvisibilityfrom p every frame. CSS never animates them, so they scrub backwards too. - Vary
data-placebetween reels on desktop. It keeps the composition moving, and the free rect follows automatically. - Use a frosted card rather than bare text over a drawing. Contrast must hold even if a glow sits near the edge.
- Keep the copy short, because phone bottom sheets must not eat more than about 40% of the stage. At 360 × 740 a kicker, a two-line title and three lines of body is the limit.
- No two captions at once (see the timeline math in engine).
The HUD
A thin strip across the top: a title, a counter ([data-sm-count]) and a progress bar ([data-sm-bar], scaleX(p)). It tells the reader that this is a sequence and how far through it they are. It is a keep-out.
The story drum layout
<section class="sd" style="height: calc(5 * 70vh + 100vh)">
<div class="sd-stage"> <!-- sticky grid: copy | drum | rail -->
<div class="sd-copy">kicker, title, one .sd-cap per chapter (hidden), ‹ › buttons</div>
<div class="sd-viewport" tabindex="0"> <!-- perspective, mask, focus target -->
<div class="sd-ring"> <!-- preserve-3d, rotated by p -->
<a class="sd-face" href="…">art + meta</a> × n
</div>
</div>
<ol class="sd-rail"><i><i data-sd-bar></i></i><li><button data-sd-go="0">…</button></li>…</ol>
</div>
</section>.sd-stage { position: sticky; top: 0; height: 100svh; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 1.1fr) 180px; align-items: center; }
.sd-viewport { --r: clamp(180px, 19vw, 260px); height: min(74vh, 600px); perspective: 1300px;
display: grid; place-items: center; touch-action: pan-y;
mask-image: linear-gradient(to bottom, transparent, #000 20%, #000 80%, transparent); }
.sd-ring { position: relative; width: 100%; height: calc(var(--r) * 1.12); transform-style: preserve-3d; }
.sd-face { position: absolute; inset: 0; backface-visibility: hidden; }
/* each face: rotateX(-i·360/n deg) translateZ(var(--r)); ring: translateZ(-r) rotateX(turn·360/n deg) */
@media (max-width: 1000px) {
.sd-stage { grid-template-columns: minmax(0, 1fr); }
.sd-rail, .sd-title, .sd-tip, .sd-d { display: none; }
.sd-viewport { --r: clamp(150px, 40vw, 220px); height: min(52vh, 440px); }
.sd-copy { order: 2; }
.sd-nav button { width: 44px; height: 44px; }
}--ris the cylinder radius. The ring'stranslateZ(-r)pulls the front face back to the screen plane, so it renders at 1:1.- The vertical mask fades the neighbour faces at the top and bottom edges. You see that it is a drum without the neighbours competing.
minmax(0, 1fr)(not1fr) on phones stops long words from forcing horizontal overflow.- Give
.sd-capamin-height, so the buttons below do not jump between chapters of different lengths.
Breakpoints that matter
| Width | What changes |
|---|---|
| < 760 | film captions become bottom sheets; env.mobile true; HUD extras and hint hidden |
| < 1000 | drum stacks (drum on top, copy below); rail, title and body copy hidden; 44 px buttons |
| ≥ 1000 | drum three-column grid |
| ≥ 1440 | nothing new: let clamp() carry it, cap the drum stage at 1320 px |
references/motion-grammar.md5.8 KB
Motion grammar
Scroll-driven motion has one property that timed animation does not: the reader controls the speed and the direction. Everything here follows from that. A sequence must read well at any speed, forwards or backwards, and when stopped at any point.
Every reel has three acts
t: 0 ─────── 0.25 ─────────────── 0.75 ─────── 1
│ enter │ hold │ exit │
build-up the readable state settle / hand-off- Enter (about the first 25%): things arrive. Lines start drawing, items stagger in, the core grows.
- Hold (the middle): the frame is complete enough to read while the caption is fully visible. Keep secondary motion here, such as slow rotation, the head travelling or a phase shift, so it does not feel frozen. Nothing new should demand attention.
- Exit (the last 25%): resolve, then hand off. The crossfade to the next reel happens around the boundary, and the reel should look finished going into it.
Map this with windows:
const grow = ease.outCubic(seg(t, 0.00, 0.30)); // enter
const spokes = seg(t, 0.45, 0.80); // a second layer, in the hold
const fin = ease.outCubic(seg(t, 0.78, 1.00)); // resolveThe timeline across reels
| Constant | Default | Meaning |
|---|---|---|
cross |
0.12 | reels crossfade over ±12% of a reel around each boundary |
capFade |
0.10 | a caption fades over 10% of a reel |
| caption visible | u ∈ [cross + capFade, 1 − cross − capFade] |
about 56% of each reel at full opacity |
The first reel starts fully drawn with its caption up, so the first frame of the pin is never empty. The last reel never fades out, so the film ends on its resolved frame and the page scrolls on.
Pacing: how tall is the track?
| Content | Scroll per unit |
|---|---|
| Film reel | 80 svh (phone) to 90 svh (desktop) per reel, plus 100 svh for the pin |
| Drum chapter | 70 vh per chapter, plus 100 vh |
| Route with N milestones | 40 to 46 vh per milestone, plus 100 svh |
view mode effect (marquee, parallax) |
no track: it rides the content |
Too short and a flick of the trackpad skips a whole reel. Too long and readers think the page is stuck. If unsure, use 90 svh per reel and test with a real trackpad and a real phone.
Easing vocabulary
| Easing | Use it for | Avoid it for |
|---|---|---|
smoothstep (in-out) |
the drum's dwell inside each step, crossfades | things that should land |
outCubic |
items arriving and settling: bars, growth | travel between two places |
outBack |
small things popping: nodes, dots, a core | large shapes (the overshoot looks like a glitch) |
inOutCubic |
travel from A to B: particles, camera moves | arrivals |
| linear | the travelled length of a line, a playhead, a progress bar | anything with mass |
Linear is right for progress. The reader should feel the line advancing in step with their scroll, so the head of a route moves linearly in arc length. Easing goes on the things it reaches.
Stagger
const k = stagger(t, i, n, span); // item i's local 0..1; starts spread over (1 - span)span≈ 0.3 to 0.5 gives overlapping arrivals that read as one gesture.span<1/ngives one-at-a-time arrivals, which read as a list. Use these deliberately (milestones, steps).- Stagger along the reading direction: left to right, top to bottom, or inner ring to outer.
Dwell: the drum's signature
const f = p * (n - 1), base = Math.floor(f), t = f - base;
const turn = base + t * t * (3 - 2 * t); // smoothstep inside each stepLinear rotation means the drum is almost always between faces. With a smoothstep per step, the derivative is zero at every face, so the drum parks: a comfortable stretch of scroll in which a face sits square to the reader, then a quick roll. The site's drums use exactly this, and so does the self-rolling preview, written as CSS keyframes that hold for 18% and roll for 7%.
Direction symmetry
Scrubbing backwards plays every act in reverse. Check each reel backwards on purpose:
- A route un-draws. That is good.
- Particles fly back out to their scattered positions. That works only because the starting positions come from
hash(i), notMath.random(). - One-shot effects (a flash when a node is reached) must be functions of distance-to-node, not events, or they never "un-flash".
- Caption entrances scrub too, because opacity and transform are written from p. That is why caption CSS must not animate them.
Never on a clock
Do not use a pulsing glow driven by time, a CSS animation on a reel element, or an idle wobble. All three break purity (the same p gives a different frame), keep the loop alive and drain batteries. If you want life in the hold, derive it from t: rotation by t × π, phase by t × 2π. The one exception is a decorative, aria-hidden preview card such as DrumPreview, which is a CSS loop that stops under reduced motion.
Velocity, with restraint
The R&D page's word roller skews rows by scroll velocity (d = p − lastP). That is acceptable for a decorative view-mode marquee, because it settles to zero skew when scrolling stops. Do not use velocity inside a film reel, because it makes the frame depend on how you arrived at p.
Reduced motion
| Normal | Reduced |
|---|---|
| lerp smoothing toward the scroll target | none: p equals the scroll position |
| drum rolls between faces with dwell | drum snaps face to face (Math.round(f)) |
| caption cards slide 14 px as they fade | fade only, no transform |
.sd-cap CSS entrance |
animation: none |
smooth scrollTo for controls |
instant |
| self-rolling preview drum | still, front face |
The content, the order and every control stay the same. Only the in-between movement goes.
references/performance-and-a11y.md6.3 KB
Performance and accessibility
A scroll film is a long-running piece of UI on the main thread, in the one interaction (scrolling) that users notice jank in most. It also hides its content in a canvas. Both need deliberate handling.
Performance
The loop runs only when something changes
const kick = () => { if (on && !raf) raf = requestAnimationFrame(tick); };
addEventListener("scroll", kick, { passive: true });
addEventListener("resize", kick);
new IntersectionObserver(([e]) => { on = e.isIntersecting; if (on) kick(); else cancelAnimationFrame(raf); },
{ rootMargin: "40% 0px" }).observe(track);
function tick() {
raf = 0; if (!on) return;
measure();
cur = Math.abs(target - cur) < 0.0005 ? target : cur + (target - cur) * smooth;
if (cur !== painted) { painted = cur; paint(cur); raf = requestAnimationFrame(tick); }
}- Settled means zero rAF. The verification script counts
requestAnimationFramecalls over one idle second and requires 0. - Off-screen means zero rAF. The observer stops the loop when the section is more than 40% of a viewport away.
- Passive listeners let the compositor scroll without waiting for JS.
- Scroll handlers do nothing but schedule work. They never read layout or draw.
One layout per frame, at most
Order every frame as write, then read, then draw:
- Write caption opacity and transform, plus the HUD, from p (style writes).
- Read keep-out boxes via
offset*(one layout if the writes dirtied it, otherwise free). - Draw on the canvas (no layout).
Never interleave reads and writes across several elements. That forces a layout per element.
Canvas budget
- DPR is capped at 2. A 1920 × 1080 stage at DPR 2 is 8.3 MP, which is fine for 2D. At DPR 3 it is 18.7 MP, which is not.
- Do not use
shadowBlur,filterorgetImageDatain the frame. Glows are radial gradients. - Reels with alpha 0 are skipped entirely.
- Resize through a
ResizeObserveron the stage, with onelayout()and an immediate repaint. - Do not paint offscreen drum faces every frame. Paint their stills once, then again on resize or theme change. In React, render art only for the visible face ±1.
CSS
will-change: transformon the drum ring andwill-change: opacity, transformon captions, and nowhere else.backdrop-filteron caption cards is the most expensive thing on the page. Keep it to the one visible card (visibility: hiddenon the others removes them from painting).- Do not put
overflow: hidden | auto | scrollon an ancestor of the track. It becomes the scroll container sticky attaches to, and the stage stops pinning. If you addcontainto an ancestor for performance, re-test the pin.
Never pin input
These are hard requirements, not preferences:
- No
wheelortouchmovelisteners withpreventDefault. - No
overflow: hiddenonhtmlorbody, and noposition: fixedon the body to "lock" it. - No CSS
scroll-snapon the track. Snapping fights the reader's own speed. - The drum's arrow keys work only when the drum has focus, and they call
scrollTo. Arrow keys anywhere else scroll the page as usual. - Drag is mouse and pen only (
pointerType !== "touch"). Touch already scrolls, andtouch-action: pan-yon the viewport keeps it that way. - After a drag that moved more than 4 px, swallow exactly one click (capture phase, removed after 50 ms), so releasing over a face does not follow its link.
Reduced motion
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;| What | With reduce |
|---|---|
| scroll smoothing | off: p is the scroll position |
| drum rotation | whole faces only |
| caption transform | none (opacity still follows p) |
.sd-cap entrance animation |
animation: none |
| programmatic seeks | instant |
| decorative self-running loops | stopped |
Frames still follow the scroll, because the user is driving them and nothing moves on its own. The content, the order and the controls are identical.
Keyboard and focus
- The drum viewport is
tabindex="0"with anaria-labelthat says the arrow keys change the chapter.:focus-visibleoutlines the front face. - Only the visible face is tabbable (
tabIndex = 0). The others are-1andaria-hidden="true", so Tab does not walk around the back of a cylinder. - Keys: ← ↑ for the previous chapter, → ↓ for the next, plus Home and End. Each one
preventDefaults only the key it handles. - The prev and next buttons are real
<button>s witharia-labels, disabled at the ends. Rail items are buttons witharia-current="step"on the active one. - Buttons are at least 44 × 44 px under 1000 px wide.
- Focus rings use the accent colour with a 3 to 4 px offset. Never remove outlines without a replacement.
Screen readers
- The canvas is
aria-hidden="true". Nothing important may live only in it. - Captions are real headings and paragraphs, present in the DOM in reading order the whole time (opacity, not removal), so a screen reader reads the full story top to bottom.
- Where the visible text is abbreviated (for example the PathFilm caption shows only the active milestone), add a
.sr-onlyordered list with every item. - The section has an
aria-labelthat names the sequence ("A film in five reels"). - Drum captions that are not current are
hidden. The rail and buttons expose the full chapter list, and every face is a link with a visible name.
Colour and contrast
- All colours are CSS custom properties on
:root, redefined under@media (prefers-color-scheme: dark)(guarded by:root:not([data-theme="light"])) and under:root[data-theme="dark"]for a manual toggle. - The canvas reads the same tokens (
readPalette) and re-reads them on a scheme change. - Caption text sits on a frosted card, not on the drawing, so contrast does not depend on what the reel is doing. Check body text at ≥ 4.5:1 against the card in both themes.
- Use
--sm-surface(not#fff) for the "holes" in dots, or they glow in dark mode.
Mobile specifics
100svhfor stages, so the URL bar showing or hiding does not resize the pin.env.mobile(stage < 760 px) means fewer items, thinner lines and smaller labels.- Captions become bottom sheets, and the drawing sits in the band above.
- Test with a real phone flick: if one flick skips a reel, the track is too short.
references/verification.md8.5 KB
Verification
"It looks fine on my laptop" is not verification. A scroll film has to hold at every width, in both directions, in both themes and with reduced motion. Run the automated pass, then the manual one. Do not call it done until both are clean.
The widths
| Width × height | Stands for |
|---|---|
| 360 × 740 | small Android |
| 390 × 844 @2x | iPhone 12 to 15 |
| 414 × 896 | large iPhone |
| 768 × 1024 | tablet portrait (film is mobile-layout, drum is stacked) |
| 1024 × 768 | tablet landscape / small laptop (drum goes three-column) |
| 1280 × 800 | laptop |
| 1440 × 900 | desktop (the preview stills are taken here) |
| 1920 × 1080 | large desktop |
What to check at every width
- No console errors or warnings, and no uncaught exceptions.
- No horizontal overflow:
document.documentElement.scrollWidth <= innerWidth. - Each reel shows at its position. Scroll to p = (i + 0.6) / n: the HUD says reel i + 1, caption i is at opacity 1, and the canvas actually drew something.
- Reels are distinct. The canvas hashes at the n positions are all different.
- Nothing under the captions. Inside every visible keep-out rect (inset 2 px), the canvas alpha is 0.
- It seeks both ways. The canvas hash at p = 0.33 is identical after scrolling 0.33, then 0.88, then 0.33.
- Idle is idle. Once settled, zero
requestAnimationFramecalls in one second. - The drum is a control. ArrowDown on the focused viewport advances it one chapter, and the next button advances it again. On desktop, a rail button jumps to its chapter and a mouse drag rolls the drum back. Every face is a link with art painted on it, and exactly one face is tabbable.
- Anchors seek.
#reel-stackscrolls the film to reel 3.
Then once more with prefers-reduced-motion: reduce (the drum rotation is a whole multiple of 360/n, and caption animation is none) and once with prefers-color-scheme: dark (screenshot it and look).
The script (puppeteer-core + an installed Chrome or Brave)
// verify.mjs: node verify.mjs (puppeteer-core only; no browser download)
import puppeteer from "puppeteer-core";
import { pathToFileURL } from "node:url";
import crypto from "node:crypto";
const BROWSER = process.env.BROWSER || "C:/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe";
const URL_ = pathToFileURL(process.argv[2] || "assets/demo.html").href;
const SIZES = [[360, 740], [390, 844, 2], [414, 896], [768, 1024], [1024, 768], [1280, 800], [1440, 900], [1920, 1080]];
const fails = [];
const ok = (c, m) => c || fails.push(m);
const browser = await puppeteer.launch({
executablePath: BROWSER, headless: "new",
args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--hide-scrollbars"],
});
async function open(w, h, dpr = 1, media = []) {
const page = await browser.newPage(), errors = [];
page.on("console", (m) => ["error", "warning"].includes(m.type()) && errors.push(m.text()));
page.on("pageerror", (e) => errors.push(e.message));
await page.evaluateOnNewDocument(() => { // count rAF calls for the idle check
window.__raf = 0; const r = requestAnimationFrame.bind(window);
window.requestAnimationFrame = (cb) => (window.__raf++, r(cb));
});
await page.setViewport({ width: w, height: h, deviceScaleFactor: dpr });
if (media.length) await page.emulateMediaFeatures(media);
await page.goto(URL_, { waitUntil: "load" });
return { page, errors };
}
async function filmTo(page, p) { // scroll, then wait for the lerp to settle
await page.evaluate((p) => {
const f = document.getElementById("film"), s = f.querySelector(".smf-stage");
scrollTo({ top: f.getBoundingClientRect().top + scrollY + p * (f.offsetHeight - s.offsetHeight), behavior: "instant" });
}, p);
await page.waitForFunction(() => {
const f = document.getElementById("film"), s = f.querySelector(".smf-stage");
return __demo.player.p === ScrollMotion.progressOf(f, "pin", s);
});
}
const hash = (page) => page.evaluate(() => document.querySelector(".smf-canvas").toDataURL())
.then((d) => crypto.createHash("sha1").update(d).digest("hex"));
const alphaUnderKeeps = (page) => page.evaluate(() => {
const cv = document.querySelector(".smf-canvas"), ctx = cv.getContext("2d"), dpr = cv.width / cv.clientWidth;
let max = 0;
for (const k of __demo.player.keeps) {
const x = Math.max(0, Math.ceil((k.x + 2) * dpr)), y = Math.max(0, Math.ceil((k.y + 2) * dpr));
const w = Math.min(cv.width, Math.floor((k.x + k.w - 2) * dpr)) - x;
const h = Math.min(cv.height, Math.floor((k.y + k.h - 2) * dpr)) - y;
if (w <= 0 || h <= 0) continue;
const d = ctx.getImageData(x, y, w, h).data;
for (let i = 3; i < d.length; i += 4) max = Math.max(max, d[i]);
}
return max;
});
for (const [w, h, dpr] of SIZES) {
const { page, errors } = await open(w, h, dpr);
ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${w}: overflow`);
const hashes = [];
for (let i = 0; i < 5; i++) {
await filmTo(page, i === 4 ? 0.97 : (i + 0.6) / 5);
const hud = await page.$eval("[data-sm-count]", (e) => e.textContent);
ok(hud.startsWith(String(i + 1).padStart(2, "0")), `${w}: reel ${i} HUD ${hud}`);
ok((await alphaUnderKeeps(page)) === 0, `${w}: reel ${i} drew under a caption`);
hashes.push(await hash(page));
if (w === 390 || w === 1440) await page.screenshot({ path: `shots/w${w}-reel${i + 1}.jpg`, quality: 80 });
}
ok(new Set(hashes).size === 5, `${w}: reels not distinct`);
await filmTo(page, 0.33); const a = await hash(page);
await filmTo(page, 0.88); await filmTo(page, 0.33);
ok(a === (await hash(page)), `${w}: forward/back differ`);
const r0 = await page.evaluate(() => __raf);
await new Promise((r) => setTimeout(r, 1000));
ok((await page.evaluate(() => __raf)) === r0, `${w}: rAF while idle`);
// …drum: focus .sd-viewport, press ArrowDown, expect __demo.drum.index === 1, etc.
ok(errors.length === 0, `${w}: console: ${errors.join(" | ")}`);
await page.close();
}
await browser.close();
console.log(fails.length ? "FAIL\n- " + fails.join("\n- ") : "ALL CHECKS PASSED");
process.exit(fails.length ? 1 : 0);Notes on the script:
- Use
behavior: "instant"in test scrolls. The demo setsscroll-behavior: smoothonhtml, and"auto"would animate. - Wait for the settle, not a fixed timeout. Compare the player's
ptoprogressOf(). They are exactly equal once the lerp snaps. - The SwiftShader flags make headless rendering deterministic on machines without a GPU. They are needed for WebGL and harmless for Canvas 2D.
- Expose a debug handle (
window.__demo = { player, drum }) from your page wiring. The tests rely onplayer.p,player.keepsanddrum.index. - For your own page, swap the selectors (
#film,.smf-stage,[data-sm-count]) for yours, and keep the checks.
Preview stills
Take them at 1440 × 900, DPR 1, JPEG quality 80, at a p inside each reel's hold (u ≈ 0.65):
await filmTo(page, 0.13); await page.screenshot({ path: "assets/preview-orbit.jpg", type: "jpeg", quality: 80 });
await filmTo(page, 0.33); await page.screenshot({ path: "assets/preview-path.jpg", type: "jpeg", quality: 80 });
await filmTo(page, 0.985); await page.screenshot({ path: "assets/preview-close.jpg", type: "jpeg", quality: 80 });Each should be under 150 KB. Flat backgrounds and line art compress well, and the demo's stills are about 40 to 50 KB.
The manual pass (10 minutes, every time)
- Trackpad: scroll slowly through, then fling. Every reel should get a readable hold and nothing should skip.
- Scroll backwards through the whole film. Does every reel rewind meaningfully?
- Stop mid-crossfade. Is the frame acceptable as a still?
- Real phone: flick through. Does the stage stay pinned when the URL bar hides? Do the bottom-sheet captions leave room for the drawing?
- Keyboard only: Tab to the drum, use the arrows, Enter on a face, Tab to the rail. Is the focus always visible?
- Screen reader (VoiceOver or NVDA): read the section. Is the whole story there in order, without the canvas?
- Reduced motion at the OS level: the drum snaps, nothing slides, and the content is complete.
- Dark mode at the OS level: no light-mode leftovers in the canvas, and caption contrast holds.
- Zoom to 200%: captions still fit, and there is no horizontal scroll.
- Idle: leave the tab open on the film for a minute. The performance panel should show no frames being produced.