What steps() Actually Does
In CSS animations, the timing function controls how the animated value changes between keyframes. Most timing functions — ease, linear, cubic-bezier() — interpolate smoothly, producing fluid motion. The steps() timing function is different: it divides the transition into a fixed number of discrete jumps, with no interpolation between them.
The result is a "frame-by-frame" animation — exactly what you need for sprite sheets, typewriter effects, and anything where you want crisp, non-blended steps rather than smooth movement.
/* Smooth — interpolates all values between 0 and 300px */
animation: slide 1s linear;
@keyframes slide { from { transform: translateX(0); } to { transform: translateX(300px); } }
/* Steps — jumps in 5 discrete positions: 0, 60, 120, 180, 240, 300px */
animation: slide 1s steps(5, end);
/* Same keyframes, completely different visual result */
The difference becomes obvious when you see them side by side. Both animations use exactly the same keyframes — only the timing function changes:
The linear bar glides continuously from left to right. The steps(6) bar snaps through 6 discrete positions with no movement in between. For sprite animations, you want the stepping behaviour — each jump advances to the next frame without any blending.
Sprite Sheet Animation
A sprite sheet is a single image file containing all frames of an animation laid out in a row (or grid). Instead of loading multiple image files, you load one sheet and use CSS to show one frame at a time by shifting the background-position. The steps() function is what makes this work — it jumps cleanly from frame to frame without the browser blending adjacent frames together.
/*
Sprite sheet: 8 frames, each 64px wide
Total image width: 512px (8 × 64)
*/
.sprite {
width: 64px; /* single frame width */
height: 64px; /* single frame height */
background-image: url('sprite.png');
background-repeat: no-repeat;
background-position: 0 0; /* start at frame 1 */
/* steps(8) = 8 frames, 'end' = default jump term */
animation: play 0.8s steps(8, end) infinite;
}
@keyframes play {
from { background-position: 0 0; }
to { background-position: -512px 0; } /* 8 frames × 64px = 512px total */
}
The step count in steps(n) must exactly match the number of frames in your sprite sheet. If your sheet has 8 frames but you write steps(4), the animation will play only every other frame. If you write steps(9), one frame will be cut off. Count your frames precisely.
Here is a live demonstration of how the frame positions advance with steps(6) — notice how the indicator snaps cleanly to each position rather than sliding:
Why Frames Stack on Top of Each Other
A common sprite bug: all frames appear stacked on top of each other instead of animating. This happens when you use ease or linear instead of steps(). Without stepping, the browser linearly interpolates background-position from 0 to -512px — showing a blurred blend of all frames simultaneously.
The fix is always to use steps(n) where n matches the frame count. No exceptions for sprite animations.
The Four Jump Terms
The second parameter in steps(n, term) controls exactly when the jump happens within each step interval. Understanding the four terms prevents subtle off-by-one bugs:
steps(n, end) — the default
The jump happens at the end of each step interval. The animation starts at the from keyframe value and the first jump to a new position happens after the first step duration has elapsed. This means the from value is shown for the first step and the to value is never seen (the animation ends just before the last jump). This is the correct setting for sprite sheets — the last position would be past the end of the sheet.
/* Equivalent forms */ animation-timing-function: steps(8, end); animation-timing-function: steps(8); /* end is default */ animation-timing-function: step-end; /* shorthand for steps(1, end) */
steps(n, start)
The jump happens at the start of each step interval. The from keyframe value is never seen — the animation immediately jumps to the first step position. The to value is shown for the last step. Use this for typewriter effects where you want the cursor to advance before showing the character, not after.
steps(n, jump-none)
No jump at either the start or end. This adds a virtual step at both ends, making the first and last values both visible for a full step duration. With n=4, you get 5 visible positions (including both endpoints). Use this for animations where the first and last frames are meaningfully different and both need to be seen.
steps(n, jump-both)
Jumps at both the start and end. With n=4, you effectively see n-2 intermediate positions (2 in this case), since both endpoints are skipped. Rarely used in practice but useful for very specific interpolation requirements.
Typewriter Effect with steps()
The typewriter effect is one of the most common uses of steps() outside of sprite sheets. The trick is to animate the element's width from 0 to full, combined with overflow: hidden so characters reveal as the width expands. The number of steps equals the character count:
/* "Hello World" = 11 characters including space */
.typewriter {
overflow: hidden;
white-space: nowrap;
width: 0;
font-family: monospace;
border-right: 2px solid var(--accent); /* cursor */
animation:
type 2.5s steps(11) forwards, /* 11 steps = 11 characters */
blink 0.7s step-end infinite; /* blinking cursor */
}
@keyframes type {
from { width: 0; }
to { width: 11ch; } /* ch unit = width of one character */
}
@keyframes blink {
from, to { border-color: transparent; }
50% { border-color: var(--accent); }
}
Debugging Checklist
When a steps() animation is not working as expected, work through this checklist:
- Step count matches frame count? Open your sprite sheet, count the frames, confirm
steps(n)uses the exact same number. - background-position range correct? The
tokeyframe should be-(frame-width × frame-count). For 8 frames of 64px each:-512px 0. - Using
endfor sprites? The default is correct — just ensure you have not accidentally setstartorjump-none. - Infinite looping correctly? Add
animation-iteration-count: infiniteand check that the last frame does not visually duplicate the first (usestep-enddefault to avoid this). - Image loaded? Check DevTools Network tab — a 404 on the sprite image produces an invisible element.
/* Complete sprite animation — all values accounted for */
.sprite {
width: 64px; /* 1 frame width */
height: 64px; /* 1 frame height */
background-image: url('sprite.png');
background-size: auto 64px; /* keeps height fixed, width auto */
background-repeat: no-repeat;
animation: sprite-play 1s steps(8, end) infinite;
}
@keyframes sprite-play {
from { background-position: 0 0; }
to { background-position: -512px 0; } /* -(64 × 8) = -512 */
}
Build steps() animations visually
Set frame count, jump term, duration and iteration. Copy the animation property ready to use.
Open Animation Generator →