Image Sequence
A canvas-based image sequence player with binary progressive loading for smooth playback.
Grafitty Can
Sentinel Bot
Rubber Duck
'use client'
import { useState } from 'react'
import { CanvasSequence } from '@/components/image-sequence'
import { cn } from '@/lib/utils'
const sequences = [
{
name: 'Grafitty Can',
frameCount: 71,
getImagePath: (idx: number) =>
`https://qfxa88yauvyse9vr.public.blob.vercel-storage.com/sequence-01/Lata${String(idx).padStart(2, '0')}.webp`,
},
{
name: 'Sentinel Bot',
frameCount: 71,
getImagePath: (idx: number) =>
`https://qfxa88yauvyse9vr.public.blob.vercel-storage.com/sequence-02/Bot${String(idx).padStart(2, '0')}.webp`,
},
{
name: 'Rubber Duck',
frameCount: 71,
getImagePath: (idx: number) =>
`https://qfxa88yauvyse9vr.public.blob.vercel-storage.com/sequence-03/Ducky${String(idx).padStart(2, '0')}.webp`,
},
]
function ImageSequenceDemo() {
const [activeIndex, setActiveIndex] = useState(0)
return (
<div className="flex min-h-80 w-full items-center justify-center p-8">
<div className="flex flex-wrap items-center justify-center gap-6">
{sequences.map((seq, index) => {
const isActive = index === activeIndex
return (
<div
key={seq.name}
className="relative cursor-pointer"
onMouseEnter={() => setActiveIndex(index)}
>
<CanvasSequence
frameCount={seq.frameCount}
frameDuration={33}
getImagePath={seq.getImagePath}
objectFit="contain"
isPlaying={isActive}
resetOnPlay
width={200}
height={200}
className="rounded-lg transition-opacity duration-200"
style={{ opacity: isActive ? 1 : 0.5 }}
/>
{/* Active frame indicator */}
<div
className={cn(
'pointer-events-none absolute inset-0 border-2',
isActive
? 'border-primary opacity-100'
: 'border-transparent opacity-0'
)}
>
<span className="bg-primary text-primary-foreground absolute top-0 -left-0.5 -translate-y-full px-1 py-0.5 font-mono text-xs uppercase">
{seq.name}
</span>
</div>
</div>
)
})}
</div>
</div>
)
}
export default ImageSequenceDemo
Installation
Usage
import { CanvasSequence } from '@/components/image-sequence'
<div className="h-[400px] w-[400px]">
<CanvasSequence
frameCount={71}
frameDuration={33}
getImagePath={(idx) => `/frames/frame_${String(idx).padStart(2, '0')}.webp`}
className="size-full"
/>
</div>Props
CanvasSequence
| Prop | Type | Default | Description |
|---|---|---|---|
frameCount | number | - | Total number of frames in the sequence |
frameDuration | number | - | Duration of each frame in milliseconds |
getImagePath | (frameIndex: number) => string | - | Function to generate the image URL for each frame |
width | number | - | Optional wrapper width in CSS pixels |
height | number | - | Optional wrapper height in CSS pixels |
isPlaying | boolean | true | Whether the animation is playing |
isVisible | boolean | true | Whether the canvas is visible |
loop | boolean | true | Whether to loop the animation |
preload | boolean | true | Whether to preload images on mount |
objectFit | 'contain' | 'cover' | 'fill' | 'contain' | How the image fits in the canvas |
onFrameChange | (frameIndex: number) => void | - | Callback when frame changes |
onAllFramesLoaded | () => void | - | Callback when all frames are loaded |
timeTransform | (deltaTime: number) => number | - | Transform delta time for speed control |
devicePixelRatio | number | window.devicePixelRatio | Pixel ratio for high-DPI displays |
resetOnPlay | boolean | false | Reset animation to frame 0 when playback starts |
useSequence Hook
For more control, use the useSequence hook directly:
import { useSequence } from '@/components/image-sequence'
function MyComponent() {
const { state, getFrame, getFrameByProgress } = useSequence({
frameCount: 71,
getImagePath: (idx) => `/frames/frame_${idx}.webp`,
preload: true,
onAllFramesLoaded: () => console.log('Ready!'),
})
// state.initialFramesLoaded - first and last frames are ready
// state.loadedCount - number of frames loaded
// getFrame(index) - get image for specific frame
// getFrameByProgress(0.5) - get image at 50% progress
}Binary Loading Strategy
The hook uses a binary loading strategy for progressive playback:
- First and last frames load first
- Then the middle frame
- Then middles of each half
- Continues recursively until all frames load
This ensures the full animation range is visible early at low framerate, progressively smoothing out as more frames load.