---
name: image-sequence-scroll
description: Use when you want "Cinematic, premium, product-focused" - Plays a frame-by-frame image sequence based on scroll position.
---

# Image Sequence Scroll

> **Category:** Scroll  -  **Personality:** Cinematic, premium, product-focused
>
> **Best use:** Product reveals, 3D-like demos
>
> **Ratings:** Professional 5/5 - Casual 2/5 - Exotic 4/5 - Visual impact 5/5 - Difficulty 5/5 - Performance cost 5/5

## What it is

Image-sequence scroll ties frame-by-frame playback to scroll position: as you scroll, scroll **progress** is mapped to a **frame index**, and that frame is painted to a `<canvas>` — so the visitor "scrubs" a short film by scrolling, with no video player. Apple popularised it for product pages (the AirPods / MacBook "turntable" reveals).

The canonical version preloads a folder of pre-rendered stills (`0001.jpg … 0150.jpg`) and swaps `images[i]` each frame. **No real frame set ships with this catalog**, so this demo *simulates* a sequence: one product photo is synthesised into **120 discrete frames** by a scroll-quantised camera move (zoom / focus / rotate) plus a cinematic grade. The plumbing — pin a stage, `scroll → frameIndex → drawImage` — is exactly what a real sequence uses; only the source of each frame differs.

## Dependencies / CDN

None — vanilla JS + Canvas 2D. No library, no SRI needed (a scroll listener + `requestAnimationFrame` is enough; GSAP `ScrollTrigger` is an optional alternative).

Fonts are optional (display + telemetry mono):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Sora:wght@300;400;600;700;800&display=swap" rel="stylesheet">
```

## HTML

A self-contained "scroll capsule": a rounded scroll container, a tall track that supplies the scroll runway, and a **sticky** stage that pins the canvas while you scroll.

```html
<div class="shell" id="shell">          <!-- the scroll container (rounded, clips) -->
  <div class="track">                   <!-- tall: provides the scroll distance -->
    <div class="stage">                 <!-- position:sticky → pinned while scrolling -->
      <canvas id="canvas" role="img" aria-label="Product reveal scrubbed on scroll"></canvas>
      <div class="hud">
        <span class="frame" id="frame">FRAME 000 / 120</span>
        <div class="bar"><span id="fill"></span></div>
      </div>
    </div>
  </div>
</div>
```

## CSS

The load-bearing part is the **sticky-inside-a-scroll-container** pin; everything else is theming.

```css
.shell{                       /* the scroll container */
  height: clamp(440px,74vh,650px);
  overflow: hidden auto;      /* y is scrollable; rounded corners clip the canvas */
  border-radius: 26px;
  scrollbar-width: thin;
}
.track{ height: 380%; }       /* % needs the shell's definite height; taller = slower scrub */
.stage{ position: sticky; top: 0; height: clamp(440px,74vh,650px); overflow: hidden; }
.canvas{ display:block; width:100%; height:100%; background:#06080c; }

/* Reduced-motion: collapse the runway and show ONE still frame (no scroll-jacking) */
@media (prefers-reduced-motion: reduce){
  .shell{ overflow:hidden; }
  .track{ height:100%; }
}
```

## JavaScript

Scroll progress → quantised frame index → repaint. The canvas only redraws when the frame index *changes*, and the work is coalesced into `requestAnimationFrame`. Each frame is synthesised from one image via a keyframed camera; swap that block for `ctx.drawImage(images[frame], …)` if you have a real sequence.

```js
const shell = document.getElementById('shell');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const FRAMES = 120;
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;

// camera keyframes at progress 0,.25,.5,.75,1 (zoom / focus x,y / rotation)
const Z=[1.78,1.30,1.05,1.10,1.20], FX=[.60,.55,.50,.46,.50],
      FY=[.42,.46,.50,.52,.48], ROT=[.052,.022,0,-.028,-.044];
const smooth = t => t*t*(3-2*t);
const kf = (a,p)=>{p=Math.min(1,Math.max(0,p));const n=a.length-1,x=p*n,i=Math.min(n-1,Math.floor(x));return a[i]+(a[i+1]-a[i])*smooth(x-i);};

let dpr=1, cw=0, ch=0, cur=-1, ready=false;
const img = new Image();
img.onload = ()=>{ ready=true; resize(); };
img.src = '/front-end-design-effects-catalog/assets/img/product-headphones.jpg';

function resize(){
  dpr = Math.min(devicePixelRatio||1, 2);
  cw = canvas.clientWidth; ch = canvas.clientHeight;
  if(!cw||!ch){ requestAnimationFrame(resize); return; }
  canvas.width = cw*dpr; canvas.height = ch*dpr;
  draw(cur<0 ? (reduced?Math.round(FRAMES/2):0) : cur, true);
}

function draw(frame, force){
  if(frame===cur && !force) return;
  cur = frame;
  const pf = frame/FRAMES;                         // quantised → reads as a "frame", not a smooth tween
  ctx.setTransform(dpr,0,0,dpr,0,0);
  ctx.clearRect(0,0,cw,ch); ctx.fillStyle='#06080c'; ctx.fillRect(0,0,cw,ch);
  if(ready){
    const s = Math.max(cw/img.naturalWidth, ch/img.naturalHeight)*1.14;   // cover + 1.14 overscan
    const w = img.naturalWidth*s, h = img.naturalHeight*s;
    ctx.save();
    ctx.translate(cw/2, ch/2); ctx.rotate(kf(ROT,pf)); ctx.scale(kf(Z,pf), kf(Z,pf));
    ctx.drawImage(img, -w/2 + (.5-kf(FX,pf))*w, -h/2 + (.5-kf(FY,pf))*h, w, h);
    ctx.restore();
  }
  // cinematic grade: vignette + bottom scrim for text legibility (omitted here for brevity)
}

if(reduced){
  shell.classList.add('static');     // CSS collapses the runway; draw() shows one still
}else{
  let ticking=false;
  shell.addEventListener('scroll', ()=>{ if(!ticking){ ticking=true; requestAnimationFrame(update); } }, {passive:true});
  function update(){
    ticking=false;
    const denom = shell.scrollHeight - shell.clientHeight;
    const p = denom>0 ? Math.min(1,Math.max(0, shell.scrollTop/denom)) : 0;
    const frame = Math.round(p*FRAMES);
    draw(frame, false);
    document.getElementById('frame').textContent = 'FRAME '+String(frame).padStart(3,'0')+' / '+FRAMES;
    document.getElementById('fill').style.transform = 'scaleX('+p+')';
  }
}
requestAnimationFrame(resize);
```

## How it works

- **Pin without `position:fixed`.** The stage is `position:sticky; top:0` inside a taller `.track`; the `.shell` is the scrolling element. As you scroll the runway, the stage stays glued to the top — the "pin" Apple-style sequences depend on.
- **Progress → frame.** `progress = scrollTop / (scrollHeight − clientHeight)`, then `frame = round(progress × FRAMES)`. Driving off the element's own scroll keeps the whole effect inside one contained panel (no page-length explosion).
- **Quantising is the trick.** Rounding to 1 of 121 discrete frames is what makes it *read* as a film strip rather than a smooth CSS transition — the motion steps frame-to-frame, exactly like swapping pre-rendered stills.
- **One repaint per frame.** `draw()` early-returns when the index hasn't changed, and scroll events are coalesced through `requestAnimationFrame`, so flinging the scrollbar never queues a backlog of paints.
- **Synthesising a frame.** Lacking real stills, each frame `drawImage`s the single photo through a keyframed camera (`cover`-fit + overscan, then `translate→rotate→scale` around a moving focus point), finished with a moving light sweep, vignette and bottom scrim. For a *real* sequence, delete the camera math and just `ctx.drawImage(images[frame], 0,0, cw,ch)`.

## Customization

- **Use a real sequence:** preload `frames[]` (`new Image()` each, await `decode()`), set `FRAMES = frames.length−1`, and replace the synthesis block with `ctx.drawImage(frames[frame], …)`.
- **Scrub speed:** taller `.track` (e.g. `420%`) = slower, more deliberate scrub; shorter = snappier.
- **Smoothness vs. "filminess":** raise `FRAMES` for a glassier scrub, lower it for a more obvious stop-motion read.
- **Camera path:** edit the `Z / FX / FY / ROT` arrays (sampled at 0/.25/.5/.75/1) to choreograph the move — open on a detail, pull to a hero, push at the finale.
- **Theme:** swap the product image and the amber accent (`#f3c27a`) and grade colours to match the brand.

## Accessibility & performance

- **Reduced motion:** `prefers-reduced-motion: reduce` collapses the scroll runway (`.track{height:100%}`) and paints a single mid-sequence still — no scroll-jacking, no motion. Checked in CSS *and* in JS before the scroll listener is attached.
- **Semantics:** the canvas carries `role="img"` + a descriptive `aria-label`; the storytelling copy is real DOM text (not baked into pixels), and the frame counter uses `font-variant-numeric: tabular-nums` so it doesn't jitter.
- **GPU/paint budget:** cap `devicePixelRatio` at 2, repaint only on frame change, coalesce with `requestAnimationFrame`, and animate the progress bar with `transform: scaleX()` (compositor-friendly) rather than `width`.
- **Real sequences are heavy:** 100+ full-res JPEGs are megabytes — preload and `decode()` every frame *before* enabling scroll, serve a lower-res set to mobile, and consider an `IntersectionObserver` to defer loading until the section is near the viewport.

## Gotchas

- **Sticky must live *inside* the scrolling element.** Any ancestor with `overflow:hidden` (other than the scroll container) or a `transform`/`filter` will silently break `position:sticky`.
- **Percentage track height needs a definite container height.** `.track{height:380%}` only resolves because `.shell` has a fixed height; without it the runway collapses and nothing scrubs.
- **Nested scroll captures the wheel/touch** while the pointer is over the capsule (great for a self-contained demo, mildly surprising on a full page). On a real landing page, drive progress off the **window/section** scroll with the section pinned via sticky instead.
- **Overscan the cover-fit** (here `×1.14`); under rotation a plain cover-fit bares the canvas background at the corners.
- **Don't paint per scroll event.** Without the `frame===cur` guard and rAF coalescing, a fast scroll floods the main thread with redundant `drawImage` calls and stutters.
- **HUD must not eat scroll:** give the overlay `pointer-events:none` so wheel/touch reach the scroll container underneath.
