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 spelled end) — the value stays at the start of each interval and jumps at its end. Over steps(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; n values evenly include both: for steps(4, jump-none), 0, 0.333, 0.667, 1.
  • jump-both — jumps at both ends, producing n + 1 levels 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.

steps(4) progress with each jump termThe same four intervals; different values at the edges.steps(4) progress with each jump term1.000.750.500.250.000.000.250.500.751.00elapsed time (fraction of duration)progressjump-endjump-startjump-none
The same four intervals; different values at the edges.

Step-by-step resolution

Setting up a sprite loopFrame count and jump term must agree.Setting up a sprite loop1Export the sheet as a single row of equal-width frames.One axis of movement2Size a clipping viewport to one frame.Only the current frame is visible3Place the sheet as an img inside the viewport.Can be moved with transform4Animate translate from 0 to minus N frame widths with steps(N, jump-end).Shows frames 1 to N exactly5Choose duration as N times the frame time, such as 12 x 80 ms.Consistent frame rate6Under reduced motion, stop on a representative frame.
Frame count and jump term must agree.

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.

Which jump term for which effectMatch the term to whether the start and end values are real frames.Which jump term for which effectKeyframesJump termWhySprite sheet, Nframes0 to -N framesjump-endEnd position is past the lastframeSprite sheet, Nframes0 to -(N-1) framesjump-noneBoth ends are real framesTypewriter0 to Nchjump-end + base styleFull text at restClock second hand0 to 1turnjump-end, steps(60)1turn equals 0turnCountdown startingon a valueN to 0jump-startRarely right; check firstframe
Match the term to whether the start and end values are real frames.

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.