Scroll Image Sequence
A free scroll-scrubbed image sequence that advances frame by frame as you scroll.
Template Empire
Twenty-four frames, driven by the scrollbar

Scroll and the sequence advances a frame at a time; the page never stops moving, so wheel, keyboard and touch all carry straight through and past. Frames are decoded before the first scrub, and a frame is drawn only when the rounded index changes.
Enable reduced motion and no frames are fetched at all: the poster is the whole component, as it is with JavaScript disabled.
Customise
Code
const frames = Array.from(
{ length: 24 },
(_, i) => `/sequence/frame-${String(i).padStart(2, "0")}.webp`,
);
<ScrollImageSequence
frames={frames}
poster="/sequence/frame-00.webp"
alt="A product rotating through a full turn"
/>Scroll Image Sequence is a free scroll section for Next.js and React that draws an ordered set of frames to a canvas and advances them from scroll position, built with Motion and styled with Tailwind. You pass an array of frame URLs and a poster; the component handles decoding, drawing, the pin and the reduced motion fallback. Pinned, the section sticks to the top of the viewport for 20px of scroll per frame, using CSS position: sticky in a track that reserves the distance, and that layout is part of the server-rendered page, so a reload or the Back button returns you to the same frame.
The poster is the authored state. It is a plain server-rendered image, and with JavaScript unavailable, under prefers-reduced-motion, or before the frames have decoded, it is the entire component. No frames are fetched at all under reduced motion, so a reader who has asked for no motion does not pay for the bandwidth either.
Install
Add the @te registry to your components.json once:
{
"registries": {
"@te": "https://templateempire.io/r/{name}.json"
}
}Then install the component:
npx shadcn@latest add @te/scroll-image-sequenceUsage
import { ScrollImageSequence } from "@/components/scroll-image-sequence";
const frames = Array.from(
{ length: 24 },
(_, i) => `/sequence/frame-${String(i).padStart(2, "0")}.webp`,
);
export default function Page() {
return (
<ScrollImageSequence
frames={frames}
poster="/sequence/frame-00.webp"
alt="A product rotating through a full turn"
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| frames | string[] | required | Ordered frame URLs. 12 to 120; outside that range the component renders the poster alone and warns once in development. |
| poster | string | required | The frame shown before decode, with no JavaScript, and under reduced motion. A finished composition rather than a placeholder. |
| alt | string | required | Describes the sequence as a whole, not one frame. This is the accessible content in every state. |
| pin | boolean | true | Pin the canvas while scrubbing, for 20px of scroll per frame. Set false to scrub over the same distance in normal flow with no pinning. |
| scrub | number | 0.5 | Scroll smoothing, in seconds: the drawn frame eases to the scroll position over this time. 0 follows the scroll exactly. |
| className | string | undefined | Additional classes merged onto the section root. |
Customisation
Scroll Image Sequence declares no tokens of its own: the section is a border and a radius over your frames, and everything visible is the sequence itself. To retheme the frame, restyle the root through className or override the semantic tokens it consumes:
:root {
--border: var(--color-neutral-800);
}Frequently asked questions
- Is Scroll Image Sequence free for commercial use?
- Yes. Scroll Image Sequence is MIT licensed, so you can use it in personal and commercial projects without attribution.
- How large should the sequence be?
- This is the real cost of the pattern and it is worth being blunt about. A 120 frame sequence at photographic quality is easily several megabytes, and that lands on every visitor whether or not they scroll to it. Ship WebP or AVIF rather than PNG or JPEG, keep frames at the size they are displayed rather than at source resolution, and prefer fewer frames with a longer scrub over more frames at the same distance. The demo here is 24 frames at roughly 23 KB each, about 556 KB in total, which is at the upper end of what a landing page should spend on one section.
- Does the pin trap scrolling or hurt accessibility?
- No. The scrub follows native scrolling and never intercepts it, so wheel, trackpad, touch and keyboard keys all move through and past the section uninterrupted, and the track reserves the scroll distance up front so pinning causes no layout shift. The canvas is aria-hidden and ignores the pointer because everything it shows is described by the poster's alt text, which is present in the accessibility tree in every state. With reduced motion enabled nothing listens to scroll and no frames are fetched; switch it on after the sequence has played and the poster shows again.
- Why does the section scroll past instead of sticking?
- The pin is CSS position: sticky, which needs a clear path to the page's scroller. If any ancestor between the section and the page sets overflow to hidden, auto or scroll, sticky stops working there; use overflow: clip on that ancestor instead, which clips the same way without creating a scroll container.
- Why a canvas rather than swapping an img src?
- Swapping a src decodes on the main thread at the moment of the swap, which is what makes these sections flicker and stutter. This component decodes every frame with img.decode() before anything is drawn, holds the decoded bitmaps, and draws to a canvas only when the rounded frame index changes. Scroll fires far more often than there are frames, so drawing per scroll event is the usual cause of jank in this pattern.