ImuTechz skill · v1.0.0 · MIT

Scroll Motion Graphics

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 is the method behind the story drum on our home page — scroll it, drag it, click it — packaged so another agent can build it.

Canvas 2DZero dependenciesReact wrapper includedInteractive drum360 → 1920

Live demo · 01 / 05

Try it: roll the drum.

Pin a stage

A tall track, a sticky stage.

The section is several screens tall; the stage inside it is position: sticky. Progress through the track is the timeline — p from 0 to 1.

Read the method
scroll, drag or use the arrow keys

Install it

Free, MIT, no account. Pick one file or the whole folder.

One file

Everything inlined — the skill, its six references and the source of the assets, in reading order. Save it as scroll-motion-graphics/SKILL.md.

imutechz-scroll-motion-graphics.md

The folder

The same content as separate files, plus the runnable demo. Keep references/ and assets/ next to SKILL.md.

Start with SKILL.md

Works with any agent that loads SKILL.md files — drop the folder into one of these, or a project folder of your own:

~/.claude/skills/scroll-motion-graphics/     # Claude Code
.cursor/skills/scroll-motion-graphics/       # Cursor
.opencode/skills/scroll-motion-graphics/     # OpenCode
On this page

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

  1. p comes from the scroll position, never from a clock. Do not use performance.now(), Date, CSS animations or setInterval inside a reel. Smoothing (a lerp toward the target p) is allowed because it converges to the same frame.
  2. A reel is a pure function draw(ctx, t, env). It keeps no state between frames and never calls Math.random(); use a seeded hash(i) instead. The same (t, env) must paint the same pixels.
  3. Pin with CSS, not JS. Use a tall track (position: relative; height: N×vh) holding a position: sticky; top: 0; height: 100svh stage. Never set position: fixed on the stage, never lock the body, and never put overflow: hidden|auto|clip on an ancestor of the track.
  4. Never capture input. Use no preventDefault on wheel or touch, no overflow: hidden on 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.
  5. Seek by scrolling. Every button, rail item, key and anchor changes window.scrollY. Never set p or a rotation directly. Use behavior: "instant" for programmatic jumps that must not animate, because "auto" obeys a page's CSS scroll-behavior: smooth.
  6. 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.
  7. Measure captions every frame and keep the drawing off them. Each caption and HUD element is a keep-out (data-keep). Reels lay out inside env.free, and the engine clips the keep-outs with an even-odd clip.
  8. Never show two captions at once. Caption windows sit inside their reel with a gap at each boundary, and reels crossfade inside that gap.
  9. 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.
  10. Canvas is sized to the stage at min(devicePixelRatio, 2). Call setTransform(dpr, …) once per frame and clear it every frame.
  11. Colours come from CSS custom properties. Do not hard-code a light-mode palette into a reel. Re-read the palette on prefers-color-scheme change.
  12. 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.

  1. 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.
  2. 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).
  3. The scrub engine. Wire ScrollMotion.scrub or film (see engine) and log p. Check that it reads 0 at pin start and 1 at pin end, and that it moves both ways.
  4. Captions and HUD in the DOM. Place them per breakpoint, mark them data-keep, and let the engine drive their opacity from p.
  5. Reels, one at a time. Write draw(ctx, t, env) inside env.free (see drawing), and scrub it back and forth before writing the next one.
  6. Motion grammar. Add enter, hold and exit envelopes, staggers, dwell and crossfades (see motion-grammar).
  7. Interactivity, if it is a drum. Add face links, the rail, prev and next buttons, keys and drag, all seeking by scroll.
  8. Performance and accessibility pass. Cover the idle loop, DPR, reduced motion, focus order, screen reader text and dark mode (see performance-and-a11y).
  9. 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 + 100svh for a film and n × 70vh + 100vh for 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-h so 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

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

text
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 bottom
js
function 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

js
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 lets cur === target become 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 smooth 0.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.

js
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).

js
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:

js
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

js
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 set html { scroll-behavior: smooth }.
  • A nudge of 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:

js
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 see

The 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.

jsx
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 setState per frame. Write styles through refs inside onFrame, and set state only when a discrete index changes (the face or reel).
  • reels must be referentially stable (module scope or useMemo), 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.js is UMD. Webpack and Next hand you module.exports. Vite serves it as ESM with no exports, so it registers globalThis.ScrollMotion. ScrollFilm.jsx resolves either. Import ScrollFilm.jsx before reels.js.

Writing your own engine instead

If you port this to another framework, keep these invariants and you have kept the method:

  1. One measured target per frame, from getBoundingClientRect of the track.
  2. A lerp toward it with a snap threshold, so it can settle.
  3. rAF scheduled only by scroll, resize or visibility, and only while not settled.
  4. Passive listeners, and no preventDefault anywhere near scrolling.
  5. 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:

js
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)

js
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)

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 colours

Pattern: 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.

js
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

js
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 build

Overlapping windows (span > 1/n) look fluid. Disjoint windows look mechanical.

Pattern: particles that resolve onto a mark

js
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:

js
const ampMax = (F.h / rows) * 1.1;
const rowY = (r) => F.y + ampMax + (F.h - 2 * ampMax) * (r / (rows - 1));

Palette

js
// 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

  • createLinearGradient and createRadialGradient per frame are fine (a few per reel).
  • shadowBlur, filter and getImageData in 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.font once 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.

js
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

html
<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>
css
.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: hidden goes 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":

  1. Is any ancestor overflow: hidden | auto | scroll | clip? Remove it, or use overflow-x: clip only on body (clip does not create a scroll container in modern engines, but test it).
  2. Does the stage have top: 0?
  3. Is the track taller than the stage? Sticky needs room to travel.
  4. 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.

js
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 an offsetParent, 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.

js
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:

js
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

html
<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>
css
.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, transform and visibility from p every frame. CSS never animates them, so they scrub backwards too.
  • Vary data-place between 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

html
<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>
css
.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; }
}
  • --r is the cylinder radius. The ring's translateZ(-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) (not 1fr) on phones stops long words from forcing horizontal overflow.
  • Give .sd-cap a min-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

text
 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:

js
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));   // resolve

The 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

js
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/n gives 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

js
const f = p * (n - 1), base = Math.floor(f), t = f - base;
const turn = base + t * t * (3 - 2 * t);       // smoothstep inside each step

Linear 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), not Math.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

js
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 requestAnimationFrame calls 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:

  1. Write caption opacity and transform, plus the HUD, from p (style writes).
  2. Read keep-out boxes via offset* (one layout if the writes dirtied it, otherwise free).
  3. 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, filter or getImageData in the frame. Glows are radial gradients.
  • Reels with alpha 0 are skipped entirely.
  • Resize through a ResizeObserver on the stage, with one layout() 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: transform on the drum ring and will-change: opacity, transform on captions, and nowhere else.
  • backdrop-filter on caption cards is the most expensive thing on the page. Keep it to the one visible card (visibility: hidden on the others removes them from painting).
  • Do not put overflow: hidden | auto | scroll on an ancestor of the track. It becomes the scroll container sticky attaches to, and the stage stops pinning. If you add contain to an ancestor for performance, re-test the pin.

Never pin input

These are hard requirements, not preferences:

  • No wheel or touchmove listeners with preventDefault.
  • No overflow: hidden on html or body, and no position: fixed on the body to "lock" it.
  • No CSS scroll-snap on 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, and touch-action: pan-y on 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

js
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 an aria-label that says the arrow keys change the chapter. :focus-visible outlines the front face.
  • Only the visible face is tabbable (tabIndex = 0). The others are -1 and aria-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 with aria-labels, disabled at the ends. Rail items are buttons with aria-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-only ordered list with every item.
  • The section has an aria-label that 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

  • 100svh for 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

  1. No console errors or warnings, and no uncaught exceptions.
  2. No horizontal overflow: document.documentElement.scrollWidth <= innerWidth.
  3. 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.
  4. Reels are distinct. The canvas hashes at the n positions are all different.
  5. Nothing under the captions. Inside every visible keep-out rect (inset 2 px), the canvas alpha is 0.
  6. It seeks both ways. The canvas hash at p = 0.33 is identical after scrolling 0.33, then 0.88, then 0.33.
  7. Idle is idle. Once settled, zero requestAnimationFrame calls in one second.
  8. 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.
  9. Anchors seek. #reel-stack scrolls 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)

js
// 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 sets scroll-behavior: smooth on html, and "auto" would animate.
  • Wait for the settle, not a fixed timeout. Compare the player's p to progressOf(). 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 on player.p, player.keeps and drum.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):

js
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.

Every file

15 files · 195 KB total. Each one is readable and downloadable on its own.

Extracted from the production scroll story on this site, where it carries five reels across the home page. The reels in assets/ are the same technique with the content replaced — open the demo, view source, and start from there.