Put the sheet on an element as a background, make the element the size of one frame, and animate background-position across the whole sheet with a steps() timing function. For 8 frames of 64 × 64 in one row, this is all of it:
.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>I ran every snippet on this page in a Chromium 156 browser against a test sheet of 8 numbered frames and read the computed background-position every 5 ms. The numbers below come from those runs. I didn’t run them in Safari or Firefox.
Why steps(N) ends at the full sheet width
The duration is frames divided by frame rate: 8 frames at 10 fps is 0.8s. The part that trips people up is the end position. The last frame sits at -448px (7 × 64), so that looks like the place to stop. It isn’t. steps(8) means steps(8, end), which cuts the run into 8 equal holds and makes the final jump at the moment the animation ends. The end value itself is never displayed. With to at -512px the holds land on 0, -64, -128 and so on up to -448px, each for 100 ms, and then the loop restarts.
Here is what the browser did with each variation of the rule on my 512px sheet:
| Timing and end position | Positions used | What you see |
|---|---|---|
steps(8), to -512px | 0, -64, -128 … -448 | All 8 frames, 100 ms each |
steps(8), to -448px | 0, -56, -112 … -392 | Each frame shows 8px more of the previous frame than the one before it did |
steps(7), to -512px | 0, -73.14, -146.29 … -438.86 | 7 positions, none of them on a frame after the first |
linear or nothing, to -512px | -3.2px further every 5 ms | The strip slides past like film through a gate |
steps(8, jump-none), to -448px | 0, -64, -128 … -448 | All 8 frames, 100 ms each |
The second row is the classic off-by-one: the step count is right, the distance is one frame short, and so every step is 56px where it should be 64px. The error grows by 8px per frame, which is why the first couple of frames look nearly fine and the last ones are visibly cut.
The last row is the other correct answer. jump-none makes neither an early nor a late jump, so both the start and end values are shown and you animate to the last frame:
.sprite {
animation: play 0.8s steps(8, jump-none) infinite;
}
@keyframes play {
to { background-position: -448px 0; } /* 7 x 64px: the last frame */
}For a plain forward loop the two are identical on screen. I write the jump-none form by hand anyway, because it keeps working when you play once, reverse, or switch to percentages, and the classic form doesn’t (more on each below).
A sheet with more than one row
One steps() can only walk in a straight line, and a grid needs to go across, drop down, and go across again. The dependable way is a keyframe per frame with steps(1), which holds each keyframe’s value until the next keyframe and then jumps. For a 4 × 2 grid:
.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 the frame index divided by the frame count. There is no 100% keyframe on purpose: the last frame holds from 87.5% to the end and the loop restarts at 0%. Uneven timing is free here. When I spaced the keyframes 10% apart and left the last one at 70%, frame 8 stayed up for 240 ms of the 800.
Nobody should type that by hand for 40 frames. The sprite sheet maker (for separate frame images) and the GIF to sprite sheet tool both have a Copy CSS button that writes this block for the sheet they just made, or the steps(N) version when the layout is a single row, with the real frame size, positions and duration filled in.
If the grid is full, two animations on the separate axes do the same job in three lines:
.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; } }In my test this produced the same eight positions at the same times as the keyframe version. I still wouldn’t ship it for anything but a full rectangle: 10 frames in a 4 × 3 grid leave two empty cells, and this version plays them as blank frames.
Scaling up without blur
Multiply four numbers by the same whole number: the element’s width and height, the background-size, and the end position. Then 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 one that gets forgotten. Without it the sheet stays 512px wide inside a 256px box that steps 256px at a time: in my test the box showed frames 1 to 4 side by side at their original size, then 5 to 8, then nothing for the other six steps. Without image-rendering everything is in the right place and soft, because the default auto smooths when it enlarges. MDN describes pixelated as nearest-neighbour scaling to the nearest whole multiple, with smoothing for any remainder, so whole multiples are where it looks cleanest.
Making it responsive
Pixel values don’t flex, so switch to percentages. A background-size of 800% makes an 8-frame row eight elements wide whatever the element’s width is. Percentage positions have a quirk worth knowing: 100% lines the right edge of the image up with the right edge of the box, which is the last frame, not one past it. That is the jump-none case 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; }
}The round() line keeps the scale a whole number. In a 200px column the sprite came out 192px wide, exactly 3×. With plain width: 100% it filled all 200px at 3.125×, where some art pixels have to be 3 screen pixels wide and others 4. round() has been in all current browsers since May 2024; for older ones, set a pixel width per breakpoint. For a grid, use background-size: 400% 200% (columns × 100%, rows × 100%) and positions of column ÷ (columns - 1) and row ÷ (rows - 1) as percentages. I tested that with 0, 33.333%, 66.667% and 100% across.
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;
}These all sit on top of the base .sprite rule, and three of them use the jump-none keyframes for a reason. Add forwards to the classic steps(8) rule and the sprite finishes on the to value, -512px, which is one frame past the end of the sheet. With no-repeat that is an empty box. Leave no-repeat out and the background tiles, so it snaps back to frame 1 and looks like the hold didn’t work.
If you want the sprite to play once and return to its first frame, that is simpler: animation: play 0.8s steps(8) 1 with no fill mode. When it ends, the element falls back to its own background-position of 0 0.
Reversing the classic rule has the same flaw in a smaller dose. At the exact start of each loop the computed position was -512px, and it became -448px on the next sample. Sampling the live animation once per drawn frame never caught the empty position on screen, but I’d rather not depend on that. One thing jump-none doesn’t change: in a ping-pong, frames 1 and 8 each get a double hold at the turns, because they end one pass and start the next. That usually reads as a natural pause.
Hover doesn’t exist on a touch screen, so put play-on-hover on something focusable such as a link or button. That is what the :focus-visible selector is for.
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 the sprite on frame 1, which isn’t always the best still, so point background-position at whichever frame is. With the setting emulated, the element reported no running animations and sat on -192px.
You could argue a 64px character looping in a corner isn’t what this setting is about: MDN’s examples of problem motion are scaling and panning large objects. I stop it anyway. A looping sprite is nearly always decoration, and the person who switched that setting on has told you what they want.
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 there are enough sprites that an element each gets silly. The canvas version is the 9-argument drawImage: source rectangle from the sheet, destination rectangle 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 is chosen from elapsed time, not by counting requestAnimationFrame calls, so the speed doesn’t depend on the screen’s refresh rate. In my run the tab was drawing at about 30 fps, the 100 ms frames changed 0 to 33 ms after their slots, and the 300 ms hold on frame 8 lasted 317 ms. The durations array maps straight onto the per-frame duration field in the JSON atlas our tools export. For more sprites, call drawImage once per sprite inside the same tick. One trap: resizing a canvas resets imageSmoothingEnabled to true (it did in my test), so set it after you set the size.
What goes wrong
The frames slide sideways
There is no steps(), or it got lost. A later animation shorthand (a hover rule, a media query) that doesn’t repeat the timing function resets it to ease.
You see half of two frames
The step size isn’t the frame width. Divide the end position by the step count and compare it with the element’s width: 448 ÷ 8 is 56, not 64. The other cause is the sheet itself. If the frames have spacing or a margin between them, the stride is frame plus gap and the first frame doesn’t start at 0, so rebuild the sheet with no spacing or use per-frame keyframes with the real offsets.
It’s blurry
image-rendering: pixelated is missing, or the scale isn’t a whole number, or the PNG was resized smoothly before it ever reached the browser. Check the file at 100% first.
Pixels look uneven or the art seems to wobble
This comes from frames that aren’t a whole number of screen pixels wide: a percentage width, a transform: scale(1.37), a parent sized in vw. I tried to make frames shift against each other in Chromium 156 with fractional widths, fractional offsets and a transform, and they stayed aligned. What changed was the art: at 77.3px wide, a 2px outline rendered 1px thick. So I can’t show you jitter, only uneven pixels. The cure for both is the whole-number scale from the responsive section.
The sheet is too big for a phone
A PNG that is small on disk is not small in memory. Decoded, it costs width × height × 4 bytes: a 100-frame row of 256px frames is 25,600 × 256 pixels, about 26 MB, for one sprite. I haven’t measured where a phone gives up on a CSS background, so here is the limit I can source: MDN notes that iOS caps a canvas at 4,096 × 4,096. I keep sheets inside that on both sides, which means a grid layout and per-frame keyframes once a row would run past 4,096px.
Sprite sheet, GIF or video?
| Sprite sheet (PNG) | GIF | Animated WebP or APNG | |
|---|---|---|---|
| Colour | Full colour | 256-colour palette | Full colour |
| Transparency | Full alpha | On or off per pixel | Full alpha |
| Pause, reverse, play once, pick a frame | 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 loops as its file says, and CSS can’t pause it, start it on hover, or stop it for reduced motion. Soft edges are the second reason: GIF transparency is all or nothing, so a sprite with a shadow or an anti-aliased outline gets a hard fringe on any background it wasn’t made for. File size I won’t promise either way. A sheet stores every frame whole and a GIF can store only what changed between frames, so export both and look.
If the animation is decoration that loops forever and nobody interacts with it, I’d skip the sheet and use an animated WebP: full colour, real alpha, one <img> tag. A GIF is still the right file for anything that gets pasted into a chat or a README (sprite sheet to GIF goes that way). Past a few seconds, or with photographic frames, use video: a sheet of 300 frames is the memory problem above with a longer download.