Component

Image Sequence

See on GitHub

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

pnpm dlx shadcn@latest add @joyco/image-sequence

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

PropTypeDefaultDescription
frameCountnumber-Total number of frames in the sequence
frameDurationnumber-Duration of each frame in milliseconds
getImagePath(frameIndex: number) => string-Function to generate the image URL for each frame
widthnumber-Optional wrapper width in CSS pixels
heightnumber-Optional wrapper height in CSS pixels
isPlayingbooleantrueWhether the animation is playing
isVisiblebooleantrueWhether the canvas is visible
loopbooleantrueWhether to loop the animation
preloadbooleantrueWhether 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
devicePixelRationumberwindow.devicePixelRatioPixel ratio for high-DPI displays
resetOnPlaybooleanfalseReset 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:

  1. First and last frames load first
  2. Then the middle frame
  3. Then middles of each half
  4. 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.

Related Components

Maintainers
Downloads
16Total
0 downloads today