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%.
- Scroll
- gsap
- scrolltrigger
- canvas2d
- advanced
- medium perf
- mobile: full
- reduced-motion ✓
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.
### 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. Add this effect to my existing site/section: **Scroll-scrubbed Image Sequence (Canvas)**. Keep my markup and styles; wrap the stage in a `.seq__stage` container (`position: relative; height: 100vh; overflow: hidden`) containing a `<canvas>`, a mono frame counter and my headline(s). Load GSAP 3.13+ and ScrollTrigger. Use my frame images (or generate frames procedurally if I have none) as `frames[0..N-1]`, size the canvas to `clientWidth/Height × Math.min(devicePixelRatio, 2)`, and draw with cover-fit maths. Create `const state = { frame: 0 }` and a timeline with `scrollTrigger: { trigger: '.seq__stage', start: 'top top', end: '+=300%', pin: true, scrub: 0.5, anticipatePin: 1 }`, tweening `state.frame` to `N − 1` with `snap: 'frame', ease: 'none', onUpdate: draw`. Hand headlines over at 30% and 70% of the timeline (`autoAlpha 1→0, y 0→-40` out; `autoAlpha 0→1, y 40→0` in; each 0.12 of the timeline). Respect `prefers-reduced-motion` by showing one static frame without pinning. Wrap in `gsap.context()` and revert on unmount. Do not change my fonts or colours outside the container. ### EFFECT (React / Next.js): Scroll-scrubbed Image Sequence (Canvas)
Build **Scroll-scrubbed Image Sequence (Canvas)** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/ScrollScrubbedImageSequenceCanvas.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP, ScrollTrigger);
export default function ScrollScrubbedImageSequenceCanvas() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="seq-intro">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`ScrollScrubbedImageSequenceCanvas.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### 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.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```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. 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>
```
```css
.seq-intro, .seq-outro { min-height: 100vh; display: grid; place-content: center; padding: 6vw; }
.seq-intro__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.seq-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .92; max-width: 12ch; }
.seq-intro__copy { margin-top: 2rem; max-width: 42ch; color: var(--muted); font-size: clamp(1rem, 1.2vw, 1.15rem); line-height: 1.5; }
.seq-outro__text { color: var(--muted); max-width: 40ch; text-align: center; }
.seq { position: relative; }
.seq__stage { position: relative; height: 100vh; overflow: hidden; background: var(--bg); }
.seq__canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.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%); }
.seq__hud { position: absolute; top: clamp(1.25rem, 3vw, 2.5rem); left: 6vw; right: 6vw; display: flex; justify-content: space-between; gap: 1rem; z-index: 2; font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
.seq__counter { color: var(--text); font-variant-numeric: tabular-nums; }
.seq__tag { text-align: right; }
.seq__heads { position: absolute; left: 6vw; right: 6vw; bottom: clamp(2rem, 9vh, 6rem); z-index: 2; display: grid; }
.seq__head { grid-area: 1 / 1; font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; 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); z-index: 2; }
.seq__rail-fill { position: absolute; inset: 0; background: var(--knob-accent, var(--accent)); transform-origin: top; transform: scaleY(0); }
@media (max-width: 767px) {
.seq__tag { display: none; }
.seq__rail { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.seq__head--2, .seq__head--3 { display: none; }
.seq__rail { display: none; }
}
```
```js
// Scroll-scrubbed Image Sequence — 90 procedural frames on a pinned canvas, scrubbed by scroll.
gsap.registerPlugin(ScrollTrigger);
const FRAMES = 90; // frames in the sequence
const MAX_FRAME_W = 900; // px — frame store width cap (halved in preview)
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { pinLength: 300, scrub: 0.5, handover: 0.12, ringSize: 0.27, grain: 0.16, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const preview = !!window.MotionLab?.preview;
const section = document.querySelector('.seq');
const stage = document.querySelector('.seq__stage');
const canvas = document.querySelector('.seq__canvas');
const counter = document.querySelector('.seq__counter');
const g2d = canvas.getContext('2d', { alpha: false });
let frames = [];
let frameAspect = 1;
const state = { frame: 0 };
/* ---------- design tokens → rgb ---------- */
function token(name, fallback) {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return hexToRgb(v) || fallback;
}
function hexToRgb(hex) {
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex || '');
if (!m) return null;
let h = m[1]; if (h.length === 3) h = h.split('').map((c) => c + c).join('');
const n = parseInt(h, 16);
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
const rgba = (c, a) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
/* ---------- procedural frame generator ---------- */
function makeNoiseTile(size = 128) {
const c = document.createElement('canvas'); c.width = c.height = size;
const ctx = c.getContext('2d');
const img = ctx.createImageData(size, size);
for (let i = 0; i < img.data.length; i += 4) {
const v = 90 + Math.random() * 130;
img.data[i] = img.data[i + 1] = img.data[i + 2] = v; img.data[i + 3] = 255;
}
ctx.putImageData(img, 0, 0);
return c;
}
function generateFrames(w, h) {
const bg = token('--bg', [10, 10, 10]);
const text = token('--text', [237, 237, 237]);
const accent = hexToRgb(K('accent')) || token('--accent', [212, 255, 58]);
const light = bg[0] > 128; // light theme → ink darkens instead of glows
const grain = K('grain');
const noise = makeNoiseTile();
const out = [];
const R = Math.min(w, h) * K('ringSize'); // torus major radius
const U = 40, V = 18; // segments around the torus / tube
const D = R * 4.2; // camera distance (perspective)
const cx = w * 0.56, cy = h * 0.5;
for (let i = 0; i < FRAMES; i++) {
const t = i / FRAMES; // 0 → 1 across the sequence (loops)
const c = document.createElement('canvas'); c.width = w; c.height = h;
const g = c.getContext('2d');
// backdrop
g.fillStyle = rgba(bg, 1); g.fillRect(0, 0, w, h);
// two drifting soft gradient blobs (accent + cool)
const ax = w * (0.55 + 0.2 * Math.cos(t * Math.PI * 2)), ay = h * (0.45 + 0.15 * Math.sin(t * Math.PI * 2));
let grad = g.createRadialGradient(ax, ay, 0, ax, ay, w * 0.42);
grad.addColorStop(0, rgba(accent, light ? 0.28 : 0.2)); grad.addColorStop(1, rgba(accent, 0));
g.fillStyle = grad; g.fillRect(0, 0, w, h);
const cool = light ? [120, 140, 200] : [40, 70, 120];
const bx = w * (0.4 - 0.2 * Math.cos(t * Math.PI * 2)), by = h * (0.6 - 0.15 * Math.sin(t * Math.PI * 2));
grad = g.createRadialGradient(bx, by, 0, bx, by, w * 0.5);
grad.addColorStop(0, rgba(cool, 0.45)); grad.addColorStop(1, rgba(cool, 0));
g.fillStyle = grad; g.fillRect(0, 0, w, h);
// wireframe torus — rotates a full turn on Y and half a turn on X across the sequence, tube breathes
const r = R * (0.38 + 0.1 * Math.sin(t * Math.PI * 2));
const rotY = t * Math.PI * 2, rotX = 0.9 + Math.sin(t * Math.PI * 2) * 0.5;
const cosY = Math.cos(rotY), sinY = Math.sin(rotY), cosX = Math.cos(rotX), sinX = Math.sin(rotX);
const pts = [];
for (let u = 0; u < U; u++) {
const a = u / U * Math.PI * 2;
for (let v = 0; v < V; v++) {
const b = v / V * Math.PI * 2;
let x = (R + r * Math.cos(b)) * Math.cos(a), y = (R + r * Math.cos(b)) * Math.sin(a), z = r * Math.sin(b);
let y1 = y * cosX - z * sinX, z1 = y * sinX + z * cosX; // rotate X
let x2 = x * cosY + z1 * sinY, z2 = -x * sinY + z1 * cosY; // rotate Y
const s = D / (D + z2);
pts.push({ x: cx + x2 * s, y: cy + y1 * s, d: (z2 + R + r) / (2 * (R + r)) }); // d: 0 far → 1 near
}
}
g.lineWidth = Math.max(1, w / 900);
g.lineCap = 'round';
const seg = (p, q) => {
const d = (p.d + q.d) / 2;
const col = mix(text, accent, Math.pow(d, 3) * 0.9);
g.strokeStyle = rgba(col, light ? 0.18 + d * 0.65 : 0.08 + d * 0.8);
g.beginPath(); g.moveTo(p.x, p.y); g.lineTo(q.x, q.y); g.stroke();
};
for (let u = 0; u < U; u++) for (let v = 0; v < V; v++) {
const p = pts[u * V + v];
seg(p, pts[u * V + (v + 1) % V]); // around the tube
seg(p, pts[((u + 1) % U) * V + v]); // along the ring
}
// grain: offset the tile per frame so the noise shimmers like film (light theme multiplies at 5/8 of the alpha)
if (grain > 0) {
g.globalCompositeOperation = light ? 'multiply' : 'overlay';
g.globalAlpha = light ? grain * 0.625 : grain;
g.save(); g.translate(-((i * 37) % 128), -((i * 53) % 128));
g.fillStyle = g.createPattern(noise, 'repeat'); g.fillRect(0, 0, w + 128, h + 128);
g.restore();
g.globalAlpha = 1; g.globalCompositeOperation = 'source-over';
}
out.push(c);
}
return out;
}
function buildFrames() {
const sw = Math.max(1, stage.clientWidth), sh = Math.max(1, stage.clientHeight);
frameAspect = sw / sh;
const cap = preview ? MAX_FRAME_W / 2 : MAX_FRAME_W;
const w = Math.round(Math.min(cap, sw)), h = Math.round(w / frameAspect);
frames = generateFrames(w, h);
}
/* ---------- drawing (cover-fit, DPR aware) ---------- */
function resizeCanvas() {
const dpr = Math.min(devicePixelRatio || 1, 2) * (preview ? 0.5 : 1);
canvas.width = Math.round(stage.clientWidth * dpr);
canvas.height = Math.round(stage.clientHeight * dpr);
}
function draw() {
const idx = gsap.utils.clamp(0, FRAMES - 1, Math.round(state.frame));
const f = frames[idx]; if (!f) return;
const cw = canvas.width, ch = canvas.height;
const s = Math.max(cw / f.width, ch / f.height);
const dw = f.width * s, dh = f.height * s;
g2d.drawImage(f, (cw - dw) / 2, (ch - dh) / 2, dw, dh);
counter.textContent = `${String(idx + 1).padStart(3, '0')} / ${String(FRAMES).padStart(3, '0')}`;
}
/* ---------- setup ---------- */
resizeCanvas();
let ctx, built = false, frameSig = '';
function build() {
ctx?.revert();
section.style.setProperty('--knob-accent', K('accent'));
// Only re-render the 90-frame store when a knob that is baked into the frames changed.
const sig = [K('accent'), K('ringSize'), K('grain')].join('|');
if (sig !== frameSig) { frameSig = sig; buildFrames(); }
const scrub = K('scrub') === 0 ? true : K('scrub');
const hand = K('handover');
const pinLength = '+=' + K('pinLength') + '%';
ctx = gsap.context(() => {
if (!built) gsap.from('.seq-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
if (reduced) {
state.frame = 40; // a flattering mid-sequence frame, no motion
draw();
return;
}
const tl = gsap.timeline({
defaults: { ease: 'none' },
scrollTrigger: { trigger: stage, start: 'top top', end: pinLength, pin: true, scrub, anticipatePin: 1, markers: false },
});
tl.to(state, { frame: FRAMES - 1, snap: 'frame', duration: 1, onUpdate: draw }, 0)
.to('.seq__rail-fill', { scaleY: 1, duration: 1 }, 0)
.to('.seq__head--1', { autoAlpha: 0, y: -40, duration: hand }, 0.3)
.fromTo('.seq__head--2', { autoAlpha: 0, y: 40 }, { autoAlpha: 1, y: 0, duration: hand }, 0.3)
.to('.seq__head--2', { autoAlpha: 0, y: -40, duration: hand }, 0.7)
.fromTo('.seq__head--3', { autoAlpha: 0, y: 40 }, { autoAlpha: 1, y: 0, duration: hand }, 0.7);
draw();
});
built = true;
}
build();
/* ---------- resize / theme ---------- */
let resizeTimer;
const onResize = () => {
resizeCanvas();
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const aspect = stage.clientWidth / Math.max(1, stage.clientHeight);
if (Math.abs(aspect - frameAspect) / frameAspect > 0.15) buildFrames(); // orientation changed → re-render frames
draw();
}, 150);
draw();
};
window.addEventListener('resize', onResize);
// Theme flips (data-theme on <html>) re-render the frames with the new tokens.
const themeObserver = new MutationObserver(() => { buildFrames(); draw(); });
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
// Playground: rebuild (ctx.revert() unpins and removes the spacer) with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => {
ctx?.revert();
themeObserver.disconnect();
window.removeEventListener('resize', onResize);
clearTimeout(resizeTimer);
frames.length = 0;
});
```
### EFFECT (single HTML file): Scroll-scrubbed Image Sequence (Canvas)
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Scroll-scrubbed Image Sequence (Canvas)** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll-scrubbed Image Sequence (Canvas)</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<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>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### 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.
---
**Reference implementation (use these exact values and class names):**
```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. 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>
```
```css
.seq-intro, .seq-outro { min-height: 100vh; display: grid; place-content: center; padding: 6vw; }
.seq-intro__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.seq-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .92; max-width: 12ch; }
.seq-intro__copy { margin-top: 2rem; max-width: 42ch; color: var(--muted); font-size: clamp(1rem, 1.2vw, 1.15rem); line-height: 1.5; }
.seq-outro__text { color: var(--muted); max-width: 40ch; text-align: center; }
.seq { position: relative; }
.seq__stage { position: relative; height: 100vh; overflow: hidden; background: var(--bg); }
.seq__canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.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%); }
.seq__hud { position: absolute; top: clamp(1.25rem, 3vw, 2.5rem); left: 6vw; right: 6vw; display: flex; justify-content: space-between; gap: 1rem; z-index: 2; font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
.seq__counter { color: var(--text); font-variant-numeric: tabular-nums; }
.seq__tag { text-align: right; }
.seq__heads { position: absolute; left: 6vw; right: 6vw; bottom: clamp(2rem, 9vh, 6rem); z-index: 2; display: grid; }
.seq__head { grid-area: 1 / 1; font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; 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); z-index: 2; }
.seq__rail-fill { position: absolute; inset: 0; background: var(--knob-accent, var(--accent)); transform-origin: top; transform: scaleY(0); }
@media (max-width: 767px) {
.seq__tag { display: none; }
.seq__rail { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.seq__head--2, .seq__head--3 { display: none; }
.seq__rail { display: none; }
}
```
```js
// Scroll-scrubbed Image Sequence — 90 procedural frames on a pinned canvas, scrubbed by scroll.
gsap.registerPlugin(ScrollTrigger);
const FRAMES = 90; // frames in the sequence
const MAX_FRAME_W = 900; // px — frame store width cap (halved in preview)
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { pinLength: 300, scrub: 0.5, handover: 0.12, ringSize: 0.27, grain: 0.16, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const preview = !!window.MotionLab?.preview;
const section = document.querySelector('.seq');
const stage = document.querySelector('.seq__stage');
const canvas = document.querySelector('.seq__canvas');
const counter = document.querySelector('.seq__counter');
const g2d = canvas.getContext('2d', { alpha: false });
let frames = [];
let frameAspect = 1;
const state = { frame: 0 };
/* ---------- design tokens → rgb ---------- */
function token(name, fallback) {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return hexToRgb(v) || fallback;
}
function hexToRgb(hex) {
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex || '');
if (!m) return null;
let h = m[1]; if (h.length === 3) h = h.split('').map((c) => c + c).join('');
const n = parseInt(h, 16);
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
const rgba = (c, a) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
/* ---------- procedural frame generator ---------- */
function makeNoiseTile(size = 128) {
const c = document.createElement('canvas'); c.width = c.height = size;
const ctx = c.getContext('2d');
const img = ctx.createImageData(size, size);
for (let i = 0; i < img.data.length; i += 4) {
const v = 90 + Math.random() * 130;
img.data[i] = img.data[i + 1] = img.data[i + 2] = v; img.data[i + 3] = 255;
}
ctx.putImageData(img, 0, 0);
return c;
}
function generateFrames(w, h) {
const bg = token('--bg', [10, 10, 10]);
const text = token('--text', [237, 237, 237]);
const accent = hexToRgb(K('accent')) || token('--accent', [212, 255, 58]);
const light = bg[0] > 128; // light theme → ink darkens instead of glows
const grain = K('grain');
const noise = makeNoiseTile();
const out = [];
const R = Math.min(w, h) * K('ringSize'); // torus major radius
const U = 40, V = 18; // segments around the torus / tube
const D = R * 4.2; // camera distance (perspective)
const cx = w * 0.56, cy = h * 0.5;
for (let i = 0; i < FRAMES; i++) {
const t = i / FRAMES; // 0 → 1 across the sequence (loops)
const c = document.createElement('canvas'); c.width = w; c.height = h;
const g = c.getContext('2d');
// backdrop
g.fillStyle = rgba(bg, 1); g.fillRect(0, 0, w, h);
// two drifting soft gradient blobs (accent + cool)
const ax = w * (0.55 + 0.2 * Math.cos(t * Math.PI * 2)), ay = h * (0.45 + 0.15 * Math.sin(t * Math.PI * 2));
let grad = g.createRadialGradient(ax, ay, 0, ax, ay, w * 0.42);
grad.addColorStop(0, rgba(accent, light ? 0.28 : 0.2)); grad.addColorStop(1, rgba(accent, 0));
g.fillStyle = grad; g.fillRect(0, 0, w, h);
const cool = light ? [120, 140, 200] : [40, 70, 120];
const bx = w * (0.4 - 0.2 * Math.cos(t * Math.PI * 2)), by = h * (0.6 - 0.15 * Math.sin(t * Math.PI * 2));
grad = g.createRadialGradient(bx, by, 0, bx, by, w * 0.5);
grad.addColorStop(0, rgba(cool, 0.45)); grad.addColorStop(1, rgba(cool, 0));
g.fillStyle = grad; g.fillRect(0, 0, w, h);
// wireframe torus — rotates a full turn on Y and half a turn on X across the sequence, tube breathes
const r = R * (0.38 + 0.1 * Math.sin(t * Math.PI * 2));
const rotY = t * Math.PI * 2, rotX = 0.9 + Math.sin(t * Math.PI * 2) * 0.5;
const cosY = Math.cos(rotY), sinY = Math.sin(rotY), cosX = Math.cos(rotX), sinX = Math.sin(rotX);
const pts = [];
for (let u = 0; u < U; u++) {
const a = u / U * Math.PI * 2;
for (let v = 0; v < V; v++) {
const b = v / V * Math.PI * 2;
let x = (R + r * Math.cos(b)) * Math.cos(a), y = (R + r * Math.cos(b)) * Math.sin(a), z = r * Math.sin(b);
let y1 = y * cosX - z * sinX, z1 = y * sinX + z * cosX; // rotate X
let x2 = x * cosY + z1 * sinY, z2 = -x * sinY + z1 * cosY; // rotate Y
const s = D / (D + z2);
pts.push({ x: cx + x2 * s, y: cy + y1 * s, d: (z2 + R + r) / (2 * (R + r)) }); // d: 0 far → 1 near
}
}
g.lineWidth = Math.max(1, w / 900);
g.lineCap = 'round';
const seg = (p, q) => {
const d = (p.d + q.d) / 2;
const col = mix(text, accent, Math.pow(d, 3) * 0.9);
g.strokeStyle = rgba(col, light ? 0.18 + d * 0.65 : 0.08 + d * 0.8);
g.beginPath(); g.moveTo(p.x, p.y); g.lineTo(q.x, q.y); g.stroke();
};
for (let u = 0; u < U; u++) for (let v = 0; v < V; v++) {
const p = pts[u * V + v];
seg(p, pts[u * V + (v + 1) % V]); // around the tube
seg(p, pts[((u + 1) % U) * V + v]); // along the ring
}
// grain: offset the tile per frame so the noise shimmers like film (light theme multiplies at 5/8 of the alpha)
if (grain > 0) {
g.globalCompositeOperation = light ? 'multiply' : 'overlay';
g.globalAlpha = light ? grain * 0.625 : grain;
g.save(); g.translate(-((i * 37) % 128), -((i * 53) % 128));
g.fillStyle = g.createPattern(noise, 'repeat'); g.fillRect(0, 0, w + 128, h + 128);
g.restore();
g.globalAlpha = 1; g.globalCompositeOperation = 'source-over';
}
out.push(c);
}
return out;
}
function buildFrames() {
const sw = Math.max(1, stage.clientWidth), sh = Math.max(1, stage.clientHeight);
frameAspect = sw / sh;
const cap = preview ? MAX_FRAME_W / 2 : MAX_FRAME_W;
const w = Math.round(Math.min(cap, sw)), h = Math.round(w / frameAspect);
frames = generateFrames(w, h);
}
/* ---------- drawing (cover-fit, DPR aware) ---------- */
function resizeCanvas() {
const dpr = Math.min(devicePixelRatio || 1, 2) * (preview ? 0.5 : 1);
canvas.width = Math.round(stage.clientWidth * dpr);
canvas.height = Math.round(stage.clientHeight * dpr);
}
function draw() {
const idx = gsap.utils.clamp(0, FRAMES - 1, Math.round(state.frame));
const f = frames[idx]; if (!f) return;
const cw = canvas.width, ch = canvas.height;
const s = Math.max(cw / f.width, ch / f.height);
const dw = f.width * s, dh = f.height * s;
g2d.drawImage(f, (cw - dw) / 2, (ch - dh) / 2, dw, dh);
counter.textContent = `${String(idx + 1).padStart(3, '0')} / ${String(FRAMES).padStart(3, '0')}`;
}
/* ---------- setup ---------- */
resizeCanvas();
let ctx, built = false, frameSig = '';
function build() {
ctx?.revert();
section.style.setProperty('--knob-accent', K('accent'));
// Only re-render the 90-frame store when a knob that is baked into the frames changed.
const sig = [K('accent'), K('ringSize'), K('grain')].join('|');
if (sig !== frameSig) { frameSig = sig; buildFrames(); }
const scrub = K('scrub') === 0 ? true : K('scrub');
const hand = K('handover');
const pinLength = '+=' + K('pinLength') + '%';
ctx = gsap.context(() => {
if (!built) gsap.from('.seq-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
if (reduced) {
state.frame = 40; // a flattering mid-sequence frame, no motion
draw();
return;
}
const tl = gsap.timeline({
defaults: { ease: 'none' },
scrollTrigger: { trigger: stage, start: 'top top', end: pinLength, pin: true, scrub, anticipatePin: 1, markers: false },
});
tl.to(state, { frame: FRAMES - 1, snap: 'frame', duration: 1, onUpdate: draw }, 0)
.to('.seq__rail-fill', { scaleY: 1, duration: 1 }, 0)
.to('.seq__head--1', { autoAlpha: 0, y: -40, duration: hand }, 0.3)
.fromTo('.seq__head--2', { autoAlpha: 0, y: 40 }, { autoAlpha: 1, y: 0, duration: hand }, 0.3)
.to('.seq__head--2', { autoAlpha: 0, y: -40, duration: hand }, 0.7)
.fromTo('.seq__head--3', { autoAlpha: 0, y: 40 }, { autoAlpha: 1, y: 0, duration: hand }, 0.7);
draw();
});
built = true;
}
build();
/* ---------- resize / theme ---------- */
let resizeTimer;
const onResize = () => {
resizeCanvas();
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const aspect = stage.clientWidth / Math.max(1, stage.clientHeight);
if (Math.abs(aspect - frameAspect) / frameAspect > 0.15) buildFrames(); // orientation changed → re-render frames
draw();
}, 150);
draw();
};
window.addEventListener('resize', onResize);
// Theme flips (data-theme on <html>) re-render the frames with the new tokens.
const themeObserver = new MutationObserver(() => { buildFrames(); draw(); });
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
// Playground: rebuild (ctx.revert() unpins and removes the spacer) with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => {
ctx?.revert();
themeObserver.disconnect();
window.removeEventListener('resize', onResize);
clearTimeout(resizeTimer);
frames.length = 0;
});
```
### EFFECT (Framer & Webflow notes): Scroll-scrubbed Image Sequence (Canvas)
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withScrollscrubbedImageSequenceCanvas(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis. ScrollTrigger works on the page scroller — call `ScrollTrigger.refresh()` after images load.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `seq-intro`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<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>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the pinning/scrubbing in code — IX2 cannot do pin spacing or `containerAnimation`.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### 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>
.seq-intro, .seq-outro { min-height: 100vh; display: grid; place-content: center; padding: 6vw; }
.seq-intro__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.seq-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .92; max-width: 12ch; }
.seq-intro__copy { margin-top: 2rem; max-width: 42ch; color: var(--muted); font-size: clamp(1rem, 1.2vw, 1.15rem); line-height: 1.5; }
.seq-outro__text { color: var(--muted); max-width: 40ch; text-align: center; }
.seq { position: relative; }
.seq__stage { position: relative; height: 100vh; overflow: hidden; background: var(--bg); }
.seq__canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.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%); }
.seq__hud { position: absolute; top: clamp(1.25rem, 3vw, 2.5rem); left: 6vw; right: 6vw; display: flex; justify-content: space-between; gap: 1rem; z-index: 2; font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
.seq__counter { color: var(--text); font-variant-numeric: tabular-nums; }
.seq__tag { text-align: right; }
.seq__heads { position: absolute; left: 6vw; right: 6vw; bottom: clamp(2rem, 9vh, 6rem); z-index: 2; display: grid; }
.seq__head { grid-area: 1 / 1; font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; 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); z-index: 2; }
.seq__rail-fill { position: absolute; inset: 0; background: var(--knob-accent, var(--accent)); transform-origin: top; transform: scaleY(0); }
@media (max-width: 767px) {
.seq__tag { display: none; }
.seq__rail { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.seq__head--2, .seq__head--3 { display: none; }
.seq__rail { display: none; }
}
// Scroll-scrubbed Image Sequence — 90 procedural frames on a pinned canvas, scrubbed by scroll.
gsap.registerPlugin(ScrollTrigger);
const FRAMES = 90; // frames in the sequence
const MAX_FRAME_W = 900; // px — frame store width cap (halved in preview)
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { pinLength: 300, scrub: 0.5, handover: 0.12, ringSize: 0.27, grain: 0.16, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const preview = !!window.MotionLab?.preview;
const section = document.querySelector('.seq');
const stage = document.querySelector('.seq__stage');
const canvas = document.querySelector('.seq__canvas');
const counter = document.querySelector('.seq__counter');
const g2d = canvas.getContext('2d', { alpha: false });
let frames = [];
let frameAspect = 1;
const state = { frame: 0 };
/* ---------- design tokens → rgb ---------- */
function token(name, fallback) {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return hexToRgb(v) || fallback;
}
function hexToRgb(hex) {
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex || '');
if (!m) return null;
let h = m[1]; if (h.length === 3) h = h.split('').map((c) => c + c).join('');
const n = parseInt(h, 16);
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
const rgba = (c, a) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
/* ---------- procedural frame generator ---------- */
function makeNoiseTile(size = 128) {
const c = document.createElement('canvas'); c.width = c.height = size;
const ctx = c.getContext('2d');
const img = ctx.createImageData(size, size);
for (let i = 0; i < img.data.length; i += 4) {
const v = 90 + Math.random() * 130;
img.data[i] = img.data[i + 1] = img.data[i + 2] = v; img.data[i + 3] = 255;
}
ctx.putImageData(img, 0, 0);
return c;
}
function generateFrames(w, h) {
const bg = token('--bg', [10, 10, 10]);
const text = token('--text', [237, 237, 237]);
const accent = hexToRgb(K('accent')) || token('--accent', [212, 255, 58]);
const light = bg[0] > 128; // light theme → ink darkens instead of glows
const grain = K('grain');
const noise = makeNoiseTile();
const out = [];
const R = Math.min(w, h) * K('ringSize'); // torus major radius
const U = 40, V = 18; // segments around the torus / tube
const D = R * 4.2; // camera distance (perspective)
const cx = w * 0.56, cy = h * 0.5;
for (let i = 0; i < FRAMES; i++) {
const t = i / FRAMES; // 0 → 1 across the sequence (loops)
const c = document.createElement('canvas'); c.width = w; c.height = h;
const g = c.getContext('2d');
// backdrop
g.fillStyle = rgba(bg, 1); g.fillRect(0, 0, w, h);
// two drifting soft gradient blobs (accent + cool)
const ax = w * (0.55 + 0.2 * Math.cos(t * Math.PI * 2)), ay = h * (0.45 + 0.15 * Math.sin(t * Math.PI * 2));
let grad = g.createRadialGradient(ax, ay, 0, ax, ay, w * 0.42);
grad.addColorStop(0, rgba(accent, light ? 0.28 : 0.2)); grad.addColorStop(1, rgba(accent, 0));
g.fillStyle = grad; g.fillRect(0, 0, w, h);
const cool = light ? [120, 140, 200] : [40, 70, 120];
const bx = w * (0.4 - 0.2 * Math.cos(t * Math.PI * 2)), by = h * (0.6 - 0.15 * Math.sin(t * Math.PI * 2));
grad = g.createRadialGradient(bx, by, 0, bx, by, w * 0.5);
grad.addColorStop(0, rgba(cool, 0.45)); grad.addColorStop(1, rgba(cool, 0));
g.fillStyle = grad; g.fillRect(0, 0, w, h);
// wireframe torus — rotates a full turn on Y and half a turn on X across the sequence, tube breathes
const r = R * (0.38 + 0.1 * Math.sin(t * Math.PI * 2));
const rotY = t * Math.PI * 2, rotX = 0.9 + Math.sin(t * Math.PI * 2) * 0.5;
const cosY = Math.cos(rotY), sinY = Math.sin(rotY), cosX = Math.cos(rotX), sinX = Math.sin(rotX);
const pts = [];
for (let u = 0; u < U; u++) {
const a = u / U * Math.PI * 2;
for (let v = 0; v < V; v++) {
const b = v / V * Math.PI * 2;
let x = (R + r * Math.cos(b)) * Math.cos(a), y = (R + r * Math.cos(b)) * Math.sin(a), z = r * Math.sin(b);
let y1 = y * cosX - z * sinX, z1 = y * sinX + z * cosX; // rotate X
let x2 = x * cosY + z1 * sinY, z2 = -x * sinY + z1 * cosY; // rotate Y
const s = D / (D + z2);
pts.push({ x: cx + x2 * s, y: cy + y1 * s, d: (z2 + R + r) / (2 * (R + r)) }); // d: 0 far → 1 near
}
}
g.lineWidth = Math.max(1, w / 900);
g.lineCap = 'round';
const seg = (p, q) => {
const d = (p.d + q.d) / 2;
const col = mix(text, accent, Math.pow(d, 3) * 0.9);
g.strokeStyle = rgba(col, light ? 0.18 + d * 0.65 : 0.08 + d * 0.8);
g.beginPath(); g.moveTo(p.x, p.y); g.lineTo(q.x, q.y); g.stroke();
};
for (let u = 0; u < U; u++) for (let v = 0; v < V; v++) {
const p = pts[u * V + v];
seg(p, pts[u * V + (v + 1) % V]); // around the tube
seg(p, pts[((u + 1) % U) * V + v]); // along the ring
}
// grain: offset the tile per frame so the noise shimmers like film (light theme multiplies at 5/8 of the alpha)
if (grain > 0) {
g.globalCompositeOperation = light ? 'multiply' : 'overlay';
g.globalAlpha = light ? grain * 0.625 : grain;
g.save(); g.translate(-((i * 37) % 128), -((i * 53) % 128));
g.fillStyle = g.createPattern(noise, 'repeat'); g.fillRect(0, 0, w + 128, h + 128);
g.restore();
g.globalAlpha = 1; g.globalCompositeOperation = 'source-over';
}
out.push(c);
}
return out;
}
function buildFrames() {
const sw = Math.max(1, stage.clientWidth), sh = Math.max(1, stage.clientHeight);
frameAspect = sw / sh;
const cap = preview ? MAX_FRAME_W / 2 : MAX_FRAME_W;
const w = Math.round(Math.min(cap, sw)), h = Math.round(w / frameAspect);
frames = generateFrames(w, h);
}
/* ---------- drawing (cover-fit, DPR aware) ---------- */
function resizeCanvas() {
const dpr = Math.min(devicePixelRatio || 1, 2) * (preview ? 0.5 : 1);
canvas.width = Math.round(stage.clientWidth * dpr);
canvas.height = Math.round(stage.clientHeight * dpr);
}
function draw() {
const idx = gsap.utils.clamp(0, FRAMES - 1, Math.round(state.frame));
const f = frames[idx]; if (!f) return;
const cw = canvas.width, ch = canvas.height;
const s = Math.max(cw / f.width, ch / f.height);
const dw = f.width * s, dh = f.height * s;
g2d.drawImage(f, (cw - dw) / 2, (ch - dh) / 2, dw, dh);
counter.textContent = `${String(idx + 1).padStart(3, '0')} / ${String(FRAMES).padStart(3, '0')}`;
}
/* ---------- setup ---------- */
resizeCanvas();
let ctx, built = false, frameSig = '';
function build() {
ctx?.revert();
section.style.setProperty('--knob-accent', K('accent'));
// Only re-render the 90-frame store when a knob that is baked into the frames changed.
const sig = [K('accent'), K('ringSize'), K('grain')].join('|');
if (sig !== frameSig) { frameSig = sig; buildFrames(); }
const scrub = K('scrub') === 0 ? true : K('scrub');
const hand = K('handover');
const pinLength = '+=' + K('pinLength') + '%';
ctx = gsap.context(() => {
if (!built) gsap.from('.seq-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
if (reduced) {
state.frame = 40; // a flattering mid-sequence frame, no motion
draw();
return;
}
const tl = gsap.timeline({
defaults: { ease: 'none' },
scrollTrigger: { trigger: stage, start: 'top top', end: pinLength, pin: true, scrub, anticipatePin: 1, markers: false },
});
tl.to(state, { frame: FRAMES - 1, snap: 'frame', duration: 1, onUpdate: draw }, 0)
.to('.seq__rail-fill', { scaleY: 1, duration: 1 }, 0)
.to('.seq__head--1', { autoAlpha: 0, y: -40, duration: hand }, 0.3)
.fromTo('.seq__head--2', { autoAlpha: 0, y: 40 }, { autoAlpha: 1, y: 0, duration: hand }, 0.3)
.to('.seq__head--2', { autoAlpha: 0, y: -40, duration: hand }, 0.7)
.fromTo('.seq__head--3', { autoAlpha: 0, y: 40 }, { autoAlpha: 1, y: 0, duration: hand }, 0.7);
draw();
});
built = true;
}
build();
/* ---------- resize / theme ---------- */
let resizeTimer;
const onResize = () => {
resizeCanvas();
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const aspect = stage.clientWidth / Math.max(1, stage.clientHeight);
if (Math.abs(aspect - frameAspect) / frameAspect > 0.15) buildFrames(); // orientation changed → re-render frames
draw();
}, 150);
draw();
};
window.addEventListener('resize', onResize);
// Theme flips (data-theme on <html>) re-render the frames with the new tokens.
const themeObserver = new MutationObserver(() => { buildFrames(); draw(); });
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
// Playground: rebuild (ctx.revert() unpins and removes the spacer) with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => {
ctx?.revert();
themeObserver.disconnect();
window.removeEventListener('resize', onResize);
clearTimeout(resizeTimer);
frames.length = 0;
});