steps() Timing Function for Sprite Animations
Part of Timing Functions & Easing Curves in Core CSS Animation Fundamentals.
The problem
A game-style mascot on a landing page is a 12-frame sprite sheet. Animated with the usual smooth easing, the sheet slides sideways and the frames blur into each other. Switching to steps(12) makes it flip frame by frame — but the first frame is skipped, or a blank frame appears at the end of each loop. A typewriter effect reveals one character too many. A ticking second hand lands between ticks.
steps() divides progress into discrete jumps, and almost every bug with it is about where the jumps happen relative to the start and end of the animation.
Root cause analysis: jumps, not frames
steps(n, <jump-term>) turns a smooth 0-to-1 progress into a staircase with n intervals. The jump term decides whether a jump happens at the very start, the very end, both, or neither.
jump-end(the default, also spelledend) — the value stays at the start of each interval and jumps at its end. Oversteps(4, jump-end)the output is 0, 0.25, 0.5, 0.75. The final value, 1, is never shown during the animation.jump-start(start) — jumps at the start of each interval: 0.25, 0.5, 0.75, 1. The first value, 0, is never shown.jump-none— no jump at either end;nvalues evenly include both: forsteps(4, jump-none), 0, 0.333, 0.667, 1.jump-both— jumps at both ends, producingn + 1levels between 0 and 1 exclusive.
Sprite sheets want jump-end. A sheet with 12 frames laid out left to right spans 12 frame widths. Animating from translate: 0 to translate: -12 frames with steps(12, jump-end) shows offsets 0 through -11 — exactly the 12 real frames — and never the -12 position, which would be past the last frame. With jump-start you would lose frame one and show an empty thirteenth position. With jump-none you need to animate to -11 frames instead.
Typewriters and counters want exact counts too. Revealing a 20-character string with steps(20, jump-end) over widths 0 to 20ch shows 0 to 19 characters and never the full string, unless the element holds its final state with forwards fill or base styles.
Step-by-step resolution
Production code pattern
<div class="mascot" role="img" aria-label="Waving mascot">
<img class="mascot__sheet" src="mascot-wave.png" width="1440" height="120" alt="">
</div>
.mascot {
--frames: 12;
--frame-w: 120px;
inline-size: var(--frame-w);
block-size: 120px;
overflow: clip; /* one frame visible */
}
.mascot__sheet {
display: block;
max-inline-size: none;
inline-size: calc(var(--frames) * var(--frame-w));
animation: wave calc(var(--frames) * 80ms) steps(12, jump-end) infinite;
}
@keyframes wave {
from { translate: 0 0; }
to { translate: calc(-1 * var(--frames) * var(--frame-w)) 0; } /* never shown: jump-end */
}
/* Typewriter: reveal N characters and keep the full string at rest. */
.typed {
inline-size: 22ch; /* base style is the full string */
overflow: clip;
white-space: nowrap;
font-family: var(--font-mono);
animation: type 1.8s steps(22, jump-end) backwards;
}
@keyframes type { from { inline-size: 0; } }
@media (prefers-reduced-motion: reduce) {
.mascot__sheet { animation: none; translate: calc(-4 * var(--frame-w)) 0; } /* mid-wave still */
.typed { animation: none; }
}
Rendering Impact: composite for the sprite, layout per step for the typewriter. The sheet is decoded and rasterised once and moved by whole frame widths; the typewriter changes
inline-size, which is a layout change but only 22 times in total, not every frame.
Moving the sheet with translate rather than background-position keeps the sprite on the compositor. background-position would repaint the frame viewport at every step — small, but for a page with several animated sprites the compositor version is free.
The typewriter animates only a from keyframe with backwards fill, so its resting style — the full width — is the base style, as recommended in animation-fill-mode explained. That avoids the missing final character that jump-end would otherwise leave.
Frame timing and high-refresh screens
Steps are time-based, not frame-based. A 12-step animation over 960ms changes every 80ms regardless of whether the display refreshes at 60 or 120 Hz, so sprite speed is stable across devices — unlike a requestAnimationFrame loop that advances one sprite frame per callback, the bug described in animation budgets on high-refresh-rate displays. The only per-display effect is that a step can land up to one display frame late, which is invisible at sprite frame rates.
Verification checklist
Constraints and trade-offs
- Sprite sheets must be laid out with exactly equal frame widths; a single-pixel gutter accumulates into drift.
- Very wide sheets exceed maximum texture sizes on some GPUs; split long animations into rows or multiple sheets.
steps()inside a keyframe list applies per keyframe interval, not across the whole animation.- Monospace fonts are required for character-accurate typewriter steps.
- Animated sprites convey nothing to screen readers; label the element and keep information in text.
Frequently asked questions
Why does my sprite animation show a blank frame?
The animation ends one frame width past the last frame and the jump term shows that final position. Use steps(N, jump-end) with a distance of N frames, or jump-none with N minus 1 frames.
What is the difference between steps(n, start) and steps(n, end)?
start jumps at the beginning of each interval, so the initial value is never shown; end jumps at the end, so the final value is never shown during the animation.
Should sprites use background-position or transform?
Transform. Moving an image element inside a clipping box keeps the sprite on the compositor, while background-position repaints the viewport each step.
Does steps() depend on the display’s frame rate?
No. Steps are calculated from elapsed time, so the sprite advances at the same rate on every display.
Related
- Timing Functions & Easing Curves — the parent topic
- linear() Easing Function for Custom Curves — the other piecewise timing function
- Seamless Loops with animation-direction: alternate — looping without seams