diff --git a/src/sprite.test.tsx b/src/sprite.test.tsx
new file mode 100644
index 0000000..e5377a9
--- /dev/null
+++ b/src/sprite.test.tsx
@@ -0,0 +1,59 @@
+import { test, expect } from "bun:test"
+import { Sprite } from "./sprite"
+
+test("Sprite renders div with correct dimensions", () => {
+ const html = ().toString()
+ expect(html).toContain('width:32px')
+ expect(html).toContain('height:32px')
+})
+
+test("Sprite sets background-image", () => {
+ const html = ().toString()
+ expect(html).toContain("background-image:url('/warrior.png')")
+})
+
+test("Sprite calculates background-size for horizontal strip", () => {
+ const html = ().toString()
+ expect(html).toContain('background-size:128px 32px')
+})
+
+test("Sprite generates keyframes animation", () => {
+ const html = ().toString()
+ expect(html).toContain('@keyframes sprite-')
+ expect(html).toContain('steps(4)')
+ expect(html).toContain('400ms')
+})
+
+test("Sprite keyframes animate background-position", () => {
+ const html = ().toString()
+ expect(html).toContain('from{background-position:0 0}')
+ expect(html).toContain('to{background-position:-128px 0}')
+})
+
+test("Sprite with columns calculates grid background-size", () => {
+ const html = ().toString()
+ expect(html).toContain('background-size:128px 64px')
+})
+
+test("Sprite with columns generates grid keyframes", () => {
+ const html = ().toString()
+ expect(html).toContain('0%{background-position:0px 0px}')
+ expect(html).toContain('25%{background-position:-32px 0px}')
+ expect(html).toContain('50%{background-position:0px -32px}')
+ expect(html).toContain('75%{background-position:-32px -32px}')
+})
+
+test("Sprite passes through class", () => {
+ const html = ().toString()
+ expect(html).toContain('class="my-sprite"')
+})
+
+test("Sprite passes through style", () => {
+ const html = ().toString()
+ expect(html).toContain('opacity:0.5')
+})
+
+test("Sprite pauses when playing is false", () => {
+ const html = ().toString()
+ expect(html).toContain('animation-play-state:paused')
+})
diff --git a/src/sprite.tsx b/src/sprite.tsx
new file mode 100644
index 0000000..0ab371c
--- /dev/null
+++ b/src/sprite.tsx
@@ -0,0 +1,70 @@
+type SpriteProps = {
+ src: string
+ width: number
+ height: number
+ frames: number
+ frameDuration: number
+ columns?: number
+ class?: string
+ style?: string
+ playing?: boolean
+}
+
+export const Sprite = ({
+ src,
+ width,
+ height,
+ frames,
+ frameDuration,
+ columns,
+ class: className,
+ style,
+ playing = true,
+}: SpriteProps) => {
+ const isGrid = columns !== undefined
+ const rows = isGrid ? Math.ceil(frames / columns!) : 1
+ const cols = isGrid ? columns! : frames
+
+ const sheetWidth = cols * width
+ const sheetHeight = rows * height
+ const totalDuration = frames * frameDuration
+
+ const keyframeId = `sprite-${Bun.hash(`${src}-${width}-${height}-${frames}-${frameDuration}-${columns}`).toString(36)}`
+
+ const keyframes = isGrid
+ ? generateGridKeyframes(keyframeId, width, height, frames, columns!)
+ : generateStripKeyframes(keyframeId, sheetWidth)
+
+ const divStyle = [
+ `width:${width}px`,
+ `height:${height}px`,
+ `background-image:url('${src}')`,
+ `background-size:${sheetWidth}px ${sheetHeight}px`,
+ `animation:${keyframeId} ${totalDuration}ms steps(${frames}) infinite`,
+ playing ? '' : 'animation-play-state:paused',
+ style,
+ ].filter(Boolean).join(';')
+
+ return (
+ <>
+
+
+ >
+ )
+}
+
+const generateStripKeyframes = (id: string, sheetWidth: number): string =>
+ `@keyframes ${id}{from{background-position:0 0}to{background-position:-${sheetWidth}px 0}}`
+
+const generateGridKeyframes = (id: string, width: number, height: number, frames: number, columns: number): string => {
+ const steps: string[] = []
+ for (let i = 0; i < frames; i++) {
+ const col = i % columns
+ const row = Math.floor(i / columns)
+ const x = -col * width
+ const y = -row * height
+ const percent = (i / frames) * 100
+ steps.push(`${percent}%{background-position:${x}px ${y}px}`)
+ }
+ return `@keyframes ${id}{${steps.join('')}}`
+}