Component

Spritesheet Sequencer

See on GitHub

A WAAPI-powered spritesheet animator for squared grid-based spritesheets with stepped frame animation.

Click
Look
Mail
Pointer
Smudge
150ms
'use client'

import * as React from 'react'
import { Gauge, Pause, Play } from 'lucide-react'
import { SpritesheetSequencer } from '@/components/spritesheet-sequencer'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'

const SPRITESHEETS = [
  {
    name: 'Click',
    src: 'https://qfxa88yauvyse9vr.public.blob.vercel-storage.com/spritesheets/click_2x2.png',
    frameCount: 3,
  },
  {
    name: 'Look',
    src: 'https://qfxa88yauvyse9vr.public.blob.vercel-storage.com/spritesheets/look_2x2.png',
    frameCount: 3,
  },
  {
    name: 'Mail',
    src: 'https://qfxa88yauvyse9vr.public.blob.vercel-storage.com/spritesheets/mail_3x3.png',
    frameCount: 8,
  },
  {
    name: 'Pointer',
    src: 'https://qfxa88yauvyse9vr.public.blob.vercel-storage.com/spritesheets/pointer_2x2.png',
    frameCount: 3,
  },
  {
    name: 'Smudge',
    src: 'https://qfxa88yauvyse9vr.public.blob.vercel-storage.com/spritesheets/smudge_2x2.png',
    frameCount: 3,
  },
]

const SPEED_MIN = 50
const SPEED_MAX = 500

export default function SpritesheetSequencerDemo() {
  const [isPlaying, setIsPlaying] = React.useState(true)
  const [speed, setSpeed] = React.useState(400)

  // Invert: higher slider value = faster animation (lower frameDuration)
  const frameDuration = SPEED_MIN + SPEED_MAX - speed

  return (
    <div className="flex min-h-[280px] flex-col items-center justify-center gap-6 px-6 py-12">
      {/* Spritesheet grid */}
      <div className="flex flex-wrap items-center justify-center gap-8">
        {SPRITESHEETS.map((sprite) => (
          <div key={sprite.name} className="flex flex-col items-center gap-2">
            <div className="size-16">
              <SpritesheetSequencer
                src={sprite.src}
                frameCount={sprite.frameCount}
                frameDuration={frameDuration}
                isPlaying={isPlaying}
              />
            </div>
            <span className="text-muted-foreground font-mono text-xs">
              {sprite.name}
            </span>
          </div>
        ))}
      </div>

      {/* Controls */}
      <div className="flex flex-wrap items-center justify-center gap-2">
        <Button
          variant="muted"
          size="icon-sm"
          onClick={() => setIsPlaying((p) => !p)}
        >
          {isPlaying ? <Pause /> : <Play />}
        </Button>

        <div className="bg-muted flex items-center gap-3 rounded-md px-3 h-8">
          <Gauge className="text-muted-foreground size-4.5" />
          <Slider
            value={[speed]}
            onValueChange={([value]) => setSpeed(value)}
            min={SPEED_MIN}
            max={SPEED_MAX}
            step={25}
            className="w-32 *:data-[slot='slider-track']:bg-muted-foreground/30"
          />
          <span className="text-muted-foreground min-w-[5ch] font-mono text-xs">
            {frameDuration}ms
          </span>
        </div>
      </div>
    </div>
  )
}

Installation

pnpm dlx shadcn@latest add @joyco/spritesheet-sequencer

Usage

import { SpritesheetSequencer } from '@/components/spritesheet-sequencer'
 
<div className="size-16">
  <SpritesheetSequencer
    src="/sprites/coin.png"
    frameCount={4}
    frameDuration={100}
    className="[image-rendering:pixelated]"
  />
</div>

Spritesheet Format

The component expects a squared spritesheet image with frames arranged in a grid:

frameCount = 4, gridSize = ceil(sqrt(4)) = 2
 
+-----+-----+
|  0  |  1  |
+-----+-----+
|  2  |  3  |
+-----+-----+

Frames are read left-to-right, top-to-bottom. The grid size is automatically calculated as ceil(sqrt(frameCount)), so partial grids are supported (e.g., 14 frames in a 4×4 grid).

Props

PropTypeDefaultDescription
srcstring-URL to the squared spritesheet image
frameCountnumber-Total number of frames in the spritesheet
frameDurationnumber100Duration per frame in milliseconds
isPlayingbooleantrueWhether the animation is playing
loopbooleantrueWhether to loop the animation
direction'normal' | 'reverse' | 'alternate' | 'alternate-reverse''normal'Animation playback direction
resetOnPlaybooleanfalseReset to frame 0 when isPlaying changes to true
onFrameChange(frame: number) => void-Callback fired when the current frame changes
onLoad() => void-Callback fired when the spritesheet image loads
onComplete() => void-Callback fired when animation completes (non-loop)

Examples

Basic coin animation

<SpritesheetSequencer
  src="/sprites/coin.png"
  frameCount={4}
  frameDuration={100}
/>

Controlled playback

const [isPlaying, setIsPlaying] = useState(true)
const [speed, setSpeed] = useState(100)
 
<SpritesheetSequencer
  src="/sprites/explosion.png"
  frameCount={16}
  frameDuration={speed}
  isPlaying={isPlaying}
  loop={false}
  resetOnPlay
  onComplete={() => setIsPlaying(false)}
/>
 
<button onClick={() => setIsPlaying(p => !p)}>
  {isPlaying ? 'Pause' : 'Play'}
</button>

Pixel art (8-bit style)

For crisp pixel art, disable image smoothing:

<SpritesheetSequencer
  src="/sprites/character.png"
  frameCount={8}
  frameDuration={80}
  className="[image-rendering:pixelated]"
/>

How It Works

The component uses the Web Animations API (WAAPI) with step-end easing to create discrete frame jumps.

The spritesheet is displayed via CSS background-image with calculated background-position keyframes for each frame in the grid.

Related Components

Maintainers
Downloads
3Total
0 downloads today