WebberUI

Image Sequence Scrub

The product-page effect that scrubs a set of frames with scroll progress — the view stays pinned while the imagery plays frame by frame.

This is a WebberUI Pro component

Free during the launch campaign: sign up or sign in, then hit “Copy install command” in the preview above and it installs straight away — no payment, no credit card. The command below returns 401 while you are signed out.

How to install Pro components →See the plans →

Loading preview…
npx shadcn@latest add "https://webberui.com/r/image-sequence-scrub.json?t=<install token>"

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

3
<ImageSequenceScrub />

Installation

npx shadcn@latest add "https://webberui.com/r/image-sequence-scrub.json?t=<install token>"

Or, once registries are configured in components.json, install it as @webberui/image-sequence-scrub.

Usage

Pass in a set of frame URLs in order; the canvas inside the scroll track container gets pinned and switches frames one by one as the scroll progresses.

import { ImageSequenceScrub } from "@/components/ui/image-sequence-scrub";

const frames = Array.from(
  { length: 120 },
  (_, i) => `/sequence/frame-${String(i).padStart(4, "0")}.jpg`
);

<ImageSequenceScrub
  frames={frames}
  scrollScale={4}
  fit="cover"
  smooth
  alt="Product turntable"
  stickyClassName="h-screen"
>
  <div className="flex h-full items-end p-10">
    <h2 className="text-4xl font-bold text-white">Designed to move.</h2>
  </div>
</ImageSequenceScrub>

If the effect sits inside a nested scrolling area of the page, pass that scroll container's ref to container and set the stickyClassName height to match the container.

Props

PropTypeDefaultDescription
framesstring[]Frame URLs played in order; scroll progress maps onto the array index
scrollScalenumber3The scroll track is this multiple of the pinned canvas height; higher values make the scrub finer
offsetTopnumber0Position where the pinned canvas sticks to the top (px)
fit"cover" | "contain""cover"How the image fills the canvas: fill and crop, or fit whole and center
smoothbooleanfalseWhether to smooth the scrub with a spring (adds momentum but also slight lag)
posterIndexnumber0Index of the frame used as the static poster under reduced motion
backgroundstringFill color for the letterboxing left by contain (a CSS color); transparent by default
containerRefObject<HTMLElement>Ref of the nested scroll container; the window is the scroll container by default
altstring"Scroll-scrubbed image sequence"Accessible label for the canvas
onProgress(progress, frameIndex) => voidCallback fired while scrubbing with the progress (0–1) and the frame index
onLoadProgress(loaded, total) => voidCallback for frame preloading progress
stickyClassNamestring"h-screen"Class for the pinned canvas container; it determines the canvas height

How it works

  • All frames are preloaded as Image objects on mount and drawn onto a single <canvas>, which avoids the flicker of swapping an <img> src frame by frame.
  • Each scroll only redraws when the frame index actually changes, and repeated requests are coalesced with requestAnimationFrame; DPR scaling and the cover/contain layout are both computed inside the canvas, and a ResizeObserver triggers a redraw whenever the canvas size changes.
  • The track container's height comes from the measured height of the pinned canvas multiplied by scrollScale, so the scrub distance is produced correctly whether you scroll the window or a nested container.
  • smooth feeds scroll progress through a spring, which suits fast flicks; turn it off when you want the frames pinned tightly to the scroll.

Accessibility

  • When the user has "reduce motion" enabled at the system level, it renders a single static posterIndex frame instead and does no scrubbing.
  • The canvas carries role="img" and the alt label; overlaid content does not take pointer events by default (pointer-events-none), so add pointer-events-auto yourself to interactive elements.
  • The Image load events, requestAnimationFrame, and ResizeObserver are all fully cleaned up on unmount.

On this page