Put the sheet on an element as a background, size the element to one frame, and animate background-position across the sheet with a steps() timing function. For 8 frames of 64 × 64 in one row:
.sprite { width: 64px; /* one frame */ height: 64px; background: url("walk.png") 0 0 no-repeat; animation: play 0.8s steps(8) infinite; /* 8 frames at 10 fps */ } @keyframes play { to { background-position: -512px 0; } /* 8 x 64px: the whole sheet */ } <div class="sprite" role="img" aria-label="Character walking"></div> Every snippet ran in Chromium 156, reading computed background-position every 5 ms; not Safari or Firefox.
Why steps(N) ends at the full sheet width
Duration is frames over frame rate. The end position trips people up: the last frame sits at -448px (7 × 64), so that looks like the stop. But steps(8) is steps(8, end) — 8 equal holds, the last jump at the end — so the to value never shows. Ending at -512px lands the holds on 0, -64, -128 … -448.
The classic bug mirrors it: end at -448px with steps(8) and every step is 56px, so the first frames look fine and the last show two halves.
The jump-none form is the other correct answer, and I write it by hand even though a forward loop looks identical:
.sprite { animation: play 0.8s steps(8, jump-none) infinite; } @keyframes play { to { background-position: -448px 0; } /* 7 x 64px: the last frame */ } A sheet with more than one row
One steps() walks in a straight line; a grid must go across, drop, and go across again. Use one keyframe per frame with steps(1), which holds each value until the next:
.sprite { width: 64px; height: 64px; background: url("walk-grid.png") 0 0 no-repeat; /* 4 columns x 2 rows */ animation: play 0.8s steps(1) infinite; } @keyframes play { 0% { background-position: 0 0; } 12.5% { background-position: -64px 0; } 25% { background-position: -128px 0; } 37.5% { background-position: -192px 0; } 50% { background-position: 0 -64px; } 62.5% { background-position: -64px -64px; } 75% { background-position: -128px -64px; } 87.5% { background-position: -192px -64px; } } Each percentage is frame index over frame count, with no 100% keyframe on purpose, so the last frame holds to the end. Uneven timing is free — keyframes 10% apart held frame 8 for 240 ms.
Nobody types that for 40 frames. The GIF to sprite sheet tool and its sprite sheet maker both write this block, or the steps(N) version for a single row, from a Copy CSS button.
For a full grid, two animations on separate axes do the same job:
.sprite { width: 64px; height: 64px; background: url("walk-grid.png") 0 0 no-repeat; animation: across 0.4s steps(4) infinite, /* one row: 4 frames */ down 0.8s steps(2) infinite; /* 2 rows, each lasting one "across" */ } @keyframes across { to { background-position-x: -256px; } } @keyframes down { to { background-position-y: -128px; } } Same eight positions at the same times in my test — but not with empty cells, which play as blanks.
Scaling up without blur
Multiply four numbers by one whole number — width, height, background-size, end position — and add image-rendering: pixelated. At 4×:
.sprite { width: 256px; /* 64 x 4 */ height: 256px; background: url("walk.png") 0 0 / 2048px 256px no-repeat; /* sheet size x 4 */ image-rendering: pixelated; animation: play 0.8s steps(8) infinite; } @keyframes play { to { background-position: -2048px 0; } } background-size is the forgotten one. Without it the 512px sheet sits in a 256px box stepping 256px at a time, so I got frames 1–4, then 5–8, then nothing. MDN defines pixelated as nearest-neighbour scaling to the nearest whole multiple.
Making it responsive
Percentages instead of pixels. background-size: 800% makes an 8-frame row eight elements wide at any width. Positions measure from the far edge: 100% puts the last frame in the box, not one past it — jump-none again.
.sprite { width: round(down, 100%, 64px); /* largest whole multiple of the frame that fits */ max-width: 256px; aspect-ratio: 1 / 1; background: url("walk.png") 0 0 / 800% 100% no-repeat; /* 8 frames wide */ image-rendering: pixelated; animation: play 0.8s steps(8, jump-none) infinite; } @keyframes play { to { background-position: 100% 0; } } round() keeps the scale whole: 192px in a 200px column, exactly 3×, where plain width: 100% gave 3.125× and mixed 3- and 4-pixel art pixels. It shipped in current browsers in May 2024; otherwise set a pixel width per breakpoint. For a grid, use background-size: 400% 200% and offsets of column ÷ (columns - 1).
Play once, reverse, pause on hover
/* Shared keyframes that stop on the last frame */ @keyframes play-to-last { to { background-position: -448px 0; } /* (8 - 1) x 64px */ } /* Play once and hold the last frame */ .sprite.once { animation: play-to-last 0.8s steps(8, jump-none) 1 forwards; } /* Backwards, 8 down to 1 */ .sprite.reverse { animation: play-to-last 0.8s steps(8, jump-none) infinite reverse; } /* There and back: 1 to 8, then 8 to 1 */ .sprite.ping-pong { animation: play-to-last 0.8s steps(8, jump-none) infinite alternate; } /* Freeze while the pointer is over it */ .sprite.pause-on-hover:hover { animation-play-state: paused; } /* Still until hovered or focused */ .sprite.play-on-hover { animation-play-state: paused; } .sprite.play-on-hover:hover, .sprite.play-on-hover:focus-visible { animation-play-state: running; } Add forwards to the classic steps(8) rule and the sprite finishes on -512px, one frame past the sheet: an empty box under no-repeat, a snap to frame 1 without it. That is why these rules use jump-none. Hover does not exist on touch, so put play-on-hover on a focusable element — hence :focus-visible.
Respect reduced motion
@media (prefers-reduced-motion: reduce) { .sprite { animation: none; background-position: -192px 0; /* frame 4, the one that reads best as a still */ } } animation: none alone leaves frame 1 showing, which is not always the best still. In a ping-pong, frames 1 and 8 each hold twice at the turns — usually a natural pause.
Canvas, for per-frame timing or many sprites
CSS runs out when game logic picks the frame, when durations change at run time, or when one element per sprite gets silly. The canvas version is the 9-argument drawImage: source rectangle from the sheet, destination on the canvas.
<canvas id="stage"></canvas> <script> const canvas = document.getElementById("stage"); const ctx = canvas.getContext("2d"); const FRAME_W = 64, FRAME_H = 64, COLS = 4; // a 4 x 2 grid sheet const durations = [100, 100, 100, 100, 100, 100, 100, 300]; // ms per frame const total = durations.reduce((a, b) => a + b, 0); const scale = 3; const dpr = window.devicePixelRatio || 1; canvas.width = FRAME_W * scale * dpr; canvas.height = FRAME_H * scale * dpr; canvas.style.width = FRAME_W * scale + "px"; canvas.style.height = FRAME_H * scale + "px"; ctx.imageSmoothingEnabled = false; // after resizing, which resets the context const sheet = new Image(); sheet.src = "walk-grid.png"; function frameAt(ms) { let t = ms % total; for (let i = 0; i < durations.length; i++) { if (t < durations[i]) return i; t -= durations[i]; } return 0; } let start, last = -1; function tick(now) { start ??= now; const i = frameAt(now - start); if (i !== last) { last = i; const sx = (i % COLS) * FRAME_W; const sy = Math.floor(i / COLS) * FRAME_H; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(sheet, sx, sy, FRAME_W, FRAME_H, 0, 0, canvas.width, canvas.height); } requestAnimationFrame(tick); } sheet.decode().then(() => requestAnimationFrame(tick)); </script> The frame comes from elapsed time, not from counting requestAnimationFrame calls, so speed does not follow the refresh rate. My run drew at about 30 fps: 100 ms frames changed 0–33 ms late, the 300 ms hold lasted 317 ms. The durations array maps onto the duration field in the JSON atlas the tool exports. One trap: resizing a canvas resets imageSmoothingEnabled.
What goes wrong
- Frames slide sideways. No
steps(), or a lateranimationshorthand that drops the timing function, resetting it toease. - Half of two frames. The step size is not the frame width (448 ÷ 8 is 56), or the sheet has gaps, making the stride frame plus gap.
- Blurry. Missing
image-rendering: pixelated, a fractional scale, or a PNG smoothed before it reached the browser. - Uneven pixels. Frames that are not a whole number of screen pixels wide: a percentage width,
transform: scale(1.37), a parent invw. At 77.3px a 2px outline rendered 1px. - Too big for a phone. Decoded, a 100-frame row of 256px frames is about 26 MB, and iOS caps a canvas at 4,096 × 4,096.
Sprite sheet, GIF or video?
| Sheet (PNG) | GIF | Animated WebP / APNG | |
|---|---|---|---|
| Colour | Full | 256 colours | Full |
| Transparency | Full alpha | On/off | Full alpha |
| Pause, reverse, play once | Yes | No | No |
| Code needed | The CSS above | An <img> | An <img> |
Control is the reason to pick a sheet: a GIF plays when it loads, and CSS cannot pause it or stop it for reduced motion. GIF transparency is all or nothing too, so shadows get a hard fringe. File size I will not promise either way — export both and look. If nobody interacts with it, an animated WebP is simpler; past a few seconds, use video.
The full version, with every measurement and the browser sources, is at CSS sprite sheet animation on giftospritesheet.com.

