Scroll-scrubbed Image Sequence (Canvas)

A pinned full-viewport canvas plays a 90-frame sequence scrubbed by scroll — the frames are generated procedurally at load (a turning wireframe ring in soft gradients and grain), with a mono frame counter and headlines that hand over at 30% and 70%.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1668 words of exact values — rewritten live as you tune the Playground.

prompt.md · Scroll-scrubbed Image Sequence (Canvas)
### EFFECT: Scroll-scrubbed Image Sequence (Canvas)
**What it does (visual):** After a full-height intro ("Ninety frames, no video"), the user reaches a full-viewport dark stage that locks in place (pinned). It shows a single frame of a 3D-looking wireframe ring (major radius 0.27 × the shorter stage side) floating over two soft, slowly drifting radial gradients (an accent glow in #d4ff3a and a cool blue), finished with fine film grain (alpha 0.16). As the user scrolls, the stage stays put while the ring turns — a full revolution across 300% of viewport height of scrolling (one frame per ~25px of scroll at the default 300%), exactly like scrubbing a video. A mono counter in the top-left reads `001 / 090` and counts up frame by frame; a 1px vertical rail on the right fills with the accent colour (#d4ff3a) in sync. A large serif headline sits bottom-left: "A ring, turned in one pass" is visible from the start; when progress passes 30% it drifts up 40px and fades out while "Every frame drawn, not filmed" rises 40px into its place; at 70% the same hand-over happens to "Scrubbed by your own hand" — each hand-over lasts 0.12 of the pin distance. Scrolling backwards reverses everything exactly (it is scrubbed, not time-based, with 0.5s of smoothing so wheel ticks feel like fluid scrubbing). On frame 90 the pin releases and a normal outro section follows. In this demo the 90 frames are generated procedurally at load (offscreen canvases); in production the identical code drives an array of pre-decoded `<img>` frames.

**Tech stack:**
- GSAP 3.13+ with ScrollTrigger; HTML Canvas 2D (no WebGL).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger);`
- Fonts: a variable serif for the headline (demo: Fraunces 300), a mono for the counter.

**HTML structure:**
```html
<section class="seq-intro">
  <p class="seq-intro__label">Scroll · Canvas sequence</p>
  <h1 class="seq-intro__title">Ninety frames, no video</h1>
  <p class="seq-intro__copy">Every frame below is drawn at load, then scrubbed by the scrollbar like a strip of film.</p>
</section>
<section class="seq">
  <div class="seq__stage">
    <canvas class="seq__canvas" aria-hidden="true"></canvas>
    <div class="seq__hud" aria-hidden="true">
      <span class="seq__counter">001 / 090</span>
      <span class="seq__tag">Halo · turned from a single bar of brass</span>
    </div>
    <div class="seq__heads">
      <h2 class="seq__head seq__head--1">A ring, turned<br>in one pass</h2>
      <h2 class="seq__head seq__head--2">Every frame<br>drawn, not filmed</h2>
      <h2 class="seq__head seq__head--3">Scrubbed by<br>your own hand</h2>
    </div>
    <div class="seq__rail" aria-hidden="true"><span class="seq__rail-fill"></span></div>
  </div>
</section>
<section class="seq-outro"><p class="seq-outro__text">The pin releases on the last frame and the page continues normally.</p></section>
```

**CSS requirements:**
- `.seq-intro`, `.seq-outro`: `min-height: 100vh; display: grid; place-content: center; padding: 6vw;`
- `.seq-intro__title`: display serif, `font-weight: 300; font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .92; max-width: 12ch;`
- `.seq__stage`: `position: relative; height: 100vh; overflow: hidden; background: var(--bg, #0a0a0a);` — this is the pinned element.
- `.seq__canvas`: `position: absolute; inset: 0; width: 100%; height: 100%;` (CSS size; the bitmap size is set in JS = CSS size × DPR).
- `.seq__stage::after`: `content: ''; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(to top, color-mix(in srgb, var(--bg) 70%, transparent), transparent 45%);` — legibility gradient for the headline.
- `.seq__hud`: `position: absolute; top: clamp(1.25rem, 3vw, 2.5rem); left: 6vw; right: 6vw; display: flex; justify-content: space-between; z-index: 2;` mono 11px, uppercase, `letter-spacing: .12em`, muted colour; `.seq__counter { color: var(--text); font-variant-numeric: tabular-nums; }`
- `.seq__heads`: `position: absolute; left: 6vw; right: 6vw; bottom: clamp(2rem, 9vh, 6rem); z-index: 2; display: grid;` — all three headlines share `grid-area: 1 / 1` so they overlap.
- `.seq__head`: display serif, `font-weight: 300; font-size: clamp(2.4rem, 7vw, 6.5rem); line-height: .92; letter-spacing: -0.04em; max-width: 11ch; will-change: transform, opacity;` `.seq__head--2, .seq__head--3 { opacity: 0; visibility: hidden; }`
- `.seq__rail`: `position: absolute; right: 6vw; top: 50%; translate: 0 -50%; width: 1px; height: 32vh; background: color-mix(in srgb, var(--text) 14%, transparent);` `.seq__rail-fill { position: absolute; inset: 0; background: var(--accent, #d4ff3a); transform-origin: top; transform: scaleY(0); }` — accent colour `--accent: #d4ff3a`.
- Under 768px hide `.seq__tag` and `.seq__rail`.

**Animation spec (exact values):**
1. Constants: `FRAMES = 90`, `PIN_LENGTH = '+=300%'`, `SCRUB = 0.5`, `HANDOVER = 0.12` (fraction of the pin per headline swap), `RING_SIZE = 0.27`, `GRAIN = 0.16`, `ACCENT = '#d4ff3a'`, `MAX_FRAME_W = 900` (halve it for low-power/preview contexts).
2. Frame store (procedural): read `--bg` and `--text` from `getComputedStyle(document.documentElement)` and parse to RGB; the accent is `#d4ff3a` (parsed the same way). Frame size: `w = min(900, stage.clientWidth)`, `h = w / (stage.clientWidth / stage.clientHeight)` — i.e. the frames share the stage's aspect so cover-fit crops nothing. For `i` in `0..89`, `t = i / 90`, on an offscreen canvas:
   - fill `--bg`;
   - radial gradient A centred at `(w·(0.55 + 0.2·cos 2πt), h·(0.45 + 0.15·sin 2πt))`, radius `0.42w`, accent at alpha 0.2 → 0; radial gradient B centred at `(w·(0.4 − 0.2·cos 2πt), h·(0.6 − 0.15·sin 2πt))`, radius `0.5w`, `rgb(40,70,120)` alpha 0.45 → 0;
   - wireframe torus: major radius `R = 0.27·min(w,h)`, tube radius `r = R·(0.38 + 0.1·sin 2πt)`, 40 segments around the ring × 18 around the tube, rotated `rotY = 2πt` then `rotX = 0.9 + 0.5·sin 2πt`, perspective `s = D / (D + z)` with `D = 4.2R`, centre `(0.56w, 0.5h)`. Each segment is stroked once (`lineWidth = max(1, w/900)`) with colour `mix(--text, --accent, depth³·0.9)` and alpha `0.08 + 0.8·depth`, where `depth = (z + R + r) / (2(R + r))` (0 far → 1 near) — nearer lines are brighter and greener;
   - grain: a 128×128 random-grey tile drawn as a repeating pattern with `globalCompositeOperation: 'overlay'`, `globalAlpha: 0.16` (× 0.625 with `multiply` on a light theme), translated by `(−(37i mod 128), −(53i mod 128))` so it shimmers per frame.
   In production replace this with `frames[i] = <img>` from `frame_001.webp … frame_090.webp`, pre-decoded.
3. Canvas sizing: `dpr = Math.min(devicePixelRatio, 2)`; `canvas.width = stage.clientWidth·dpr; canvas.height = stage.clientHeight·dpr`. Draw function (cover-fit): `s = max(cw/fw, ch/fh); drawImage(frame, (cw − fw·s)/2, (ch − fh·s)/2, fw·s, fh·s)`; then set the counter text to `String(idx + 1).padStart(3,'0') + ' / 090'`.
4. Scrubbed timeline (all `ease: 'none'`):
   ```js
   const state = { frame: 0 };
   const tl = gsap.timeline({ defaults: { ease: 'none' },
     scrollTrigger: { trigger: '.seq__stage', start: 'top top', end: '+=300%', pin: true, scrub: 0.5, anticipatePin: 1, markers: false } });
   tl.to(state, { frame: 89, snap: 'frame', duration: 1, onUpdate: draw }, 0)          // frame index 0 → 89, snapped to integers
     .to('.seq__rail-fill', { scaleY: 1, duration: 1 }, 0)                               // rail 0 → 1 over the whole pin
     .to('.seq__head--1', { autoAlpha: 0, y: -40, duration: 0.12 }, 0.3)                 // headline 1 exits at 30%
     .fromTo('.seq__head--2', { autoAlpha: 0, y: 40 }, { autoAlpha: 1, y: 0, duration: 0.12 }, 0.3)
     .to('.seq__head--2', { autoAlpha: 0, y: -40, duration: 0.12 }, 0.7)                 // headline 2 exits at 70%
     .fromTo('.seq__head--3', { autoAlpha: 0, y: 40 }, { autoAlpha: 1, y: 0, duration: 0.12 }, 0.7);
   ```
   Timeline duration is 1, so positions `0.3`/`0.7` are 30%/70% of the pin; each hand-over lasts 0.12 of the pin distance (0.12 ≈ 36vh of scroll at the default pin length).
5. Intro: `gsap.from('.seq-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`.
6. On `resize`: resize the canvas bitmap and redraw immediately; after a 150ms debounce, if the stage aspect changed by more than 15% (orientation flip) regenerate the frame store. On a `data-theme` attribute change (MutationObserver on `<html>`) regenerate the frames with the new tokens and redraw.

**Interaction:** None beyond scrolling — wheel, trackpad, keyboard and scrollbar dragging all scrub the sequence because it reads the page scroll position. Scrolling up plays the sequence backwards.

**Responsive behavior:** The pin + scrub runs on all sizes (the stage is `100vh`; the frames are regenerated to the portrait aspect on phones so cover-fit shows the whole ring). Headline is fluid via `clamp()`. Under 768px the right-hand rail and the HUD tag are hidden, leaving only the counter. In embedded/preview contexts halve the frame width cap (450px) and the DPR to keep the frame store light.

**Accessibility:** With `prefers-reduced-motion: reduce` there is no pin and no scrub: the stage is a normal 100vh section showing frame 41 (`state.frame = 40`), the counter reads `041 / 090`, headline 1 is shown statically and headlines 2–3 plus the rail are `display: none`. The canvas is `aria-hidden="true"`; the headlines are real `<h2>` elements so the copy remains readable by assistive tech.

**Performance rules:** No free-running rAF — the canvas is repainted only inside the tween's `onUpdate`, i.e. when the frame index changes. Only `transform`/`opacity` are animated on DOM nodes; `will-change: transform, opacity` on the headlines only. Cap the DPR at 2 and the frame width at 900px (90 frames × 900×560 RGBA ≈ 180MB is the practical ceiling for the store; use 720px for a 120-frame sequence). `ease: 'none'` so scroll distance maps linearly to frames. Everything lives inside `gsap.context()` and is reverted on unmount (also unpins and removes the spacer); the frame array is emptied, the resize listener and MutationObserver removed.

**Customization knobs:**
- `FRAMES` (90) and `PIN_LENGTH` (`+=300%`) — more frames or a longer pin = slower, finer scrub.
- `SCRUB` (0.5) — 0 for 1:1 raw scrub, 1 for a heavier, more cinematic lag.
- Hand-over points (`0.3`, `0.7`) and hand-over width (`0.12`).
- `MAX_FRAME_W` (900) — frame store resolution.
- Procedural look: torus segment counts (40×18), ring radius factor (0.27), gradient alphas, grain alpha (0.16).
- Accent (`#d4ff3a`) — the rail fill and the glow/near-edge tint baked into the frames.

**Acceptance criteria:**
- [ ] The stage pins at `top top` for exactly 300% of viewport height and the counter runs `001 / 090` → `090 / 090` across that distance.
- [ ] Frames snap to integers (no blended/half frames) and the image is cover-fit with no stretching at any viewport size or DPR.
- [ ] Headline 1 → 2 hand-over happens at 30% progress, 2 → 3 at 70%; both reverse when scrolling up.
- [ ] Scrolling back to the top returns to frame 1; resizing keeps the canvas sharp.
- [ ] Reduced-motion users see a static frame and headline, no pin.
- [ ] No console errors; no network requests for images.

Source

Full source code

<section class="seq-intro">
  <p class="seq-intro__label">Scroll · Canvas sequence</p>
  <h1 class="seq-intro__title">Ninety frames, no video</h1>
  <p class="seq-intro__copy">Every frame below is drawn at load, then scrubbed by the scrollbar like a strip of film. Keep scrolling.</p>
</section>

<section class="seq">
  <div class="seq__stage">
    <canvas class="seq__canvas" aria-hidden="true"></canvas>
    <div class="seq__hud" aria-hidden="true">
      <span class="seq__counter">001 / 090</span>
      <span class="seq__tag">Halo · turned from a single bar of brass</span>
    </div>
    <div class="seq__heads">
      <h2 class="seq__head seq__head--1">A ring, turned<br>in one pass</h2>
      <h2 class="seq__head seq__head--2">Every frame<br>drawn, not filmed</h2>
      <h2 class="seq__head seq__head--3">Scrubbed by<br>your own hand</h2>
    </div>
    <div class="seq__rail" aria-hidden="true"><span class="seq__rail-fill"></span></div>
  </div>
</section>

<section class="seq-outro">
  <p class="seq-outro__text">The pin releases on the last frame and the page continues normally.</p>
</section>