steps() Easing: Sprite Sheets, Typewriters and Other Frame-by-Frame Effects

Every other timing function in CSS is about smoothness: getting from A to B without visible jumps. steps() does the opposite on purpose. It divides an animation into a fixed number of equal intervals and holds a constant value within each, producing discrete jumps — exactly what frame-by-frame animation needs. A sprite sheet of a walking character, text that types itself one character at a time, a ticking second hand, a blinking caret, a segmented progress meter: all of these are steps() effects. This page explains the step positions that trip people up and builds each of those effects. It belongs to Easing & Motion Design in the CSS-Only Micro-Interactions & Animations guide.

Why discrete timing exists

Some motion is not supposed to be continuous. A sprite sheet contains a fixed set of drawn frames; interpolating the background-position between frames would slide the sheet and show half of one drawing and half of the next. A typewriter reveals whole characters, never three-fifths of a letter. A clock's second hand, in the classic quartz style, jumps once per second. In each case the right output is a value from a small set, held until the next change.

steps(n, <position>) maps progress onto n flat plateaus. The position keyword decides where each jump happens relative to its interval, and getting it wrong is the source of almost every "my animation skips a frame" bug.

The step positions

Four ways to place four steps Four staircases of output against time. jump-end starts at zero and reaches one only at the end. jump-start jumps at the very beginning and holds one for the final interval. jump-both has five levels including both zero and one as brief plateaus. jump-none has four levels starting at zero and ending at one, each held for a quarter of the time. steps(4, position) jump-end (default) 0 first, 1 at end jump-start skips 0, holds 1 jump-both neither end held jump-none both ends held Sprite sheet, animated to "one frame past the last": steps(N) with jump-end. Frames listed as start and end values: steps(N) with jump-none.
  • jump-end (default, also end): the value changes at the end of each interval. The first plateau shows the start value; the end value is reached only at the final instant, which with infinite iteration means it is never displayed.
  • jump-start (also start): the value changes at the start of each interval. The start value is never shown; the end value is held for the last interval.
  • jump-none: neither end jumps; the start value is held for the first interval and the end value for the last. With n steps there are exactly n visible values including both ends.
  • jump-both: jumps at both ends, producing n + 1 levels, with neither the start nor the end value held for a full interval.

step-start and step-end are keyword shorthands for steps(1, jump-start) and steps(1, jump-end): a single instant change at the beginning or end.

The complete implementation

The page below builds three effects: a sprite-sheet walk cycle, a typewriter line with a blinking caret, and a ticking clock hand. The sprite sheet is drawn with a repeating gradient so the example is self-contained; in production it would be a real image.

Live demoSprite, typewriter and ticking hand with steps()
The sprite cycles eight frames, the line types itself one character per step, and the red hand jumps once a second.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>steps() effects</title>
<style>
  body { font: 16px/1.5 system-ui, sans-serif; margin: 1.5rem; display: grid; gap: 1.5rem; }

  /* 1. SPRITE SHEET: 8 frames of 48px each, laid out horizontally (384px).
     Animate background-position from frame 1 to just past frame 8 (-384px)
     with steps(8): jump-end shows frames 1..8 and never the empty "frame 9". */
  .sprite {
    width: 48px;
    height: 48px;
    background-image: repeating-linear-gradient(
      to right,
      #4f46e5 0 12px, #818cf8 12px 24px, #c7d2fe 24px 36px, #e0e7ff 36px 48px
    );
    background-size: 384px 48px;
    animation: walk 0.8s steps(8) infinite;
  }
  @keyframes walk {
    to { background-position: -384px 0; }
  }

  /* 2. TYPEWRITER: width grows one character at a time. The ch unit is
     the width of "0", so with a monospace font 1ch is one character. */
  .typed {
    font: 1.25rem/1.4 ui-monospace, monospace;
    width: 23ch;                     /* final width = 23 characters */
    white-space: nowrap;
    overflow: hidden;
    border-inline-end: 2px solid currentColor;   /* the caret */
    animation:
      type 2.3s steps(23) 0.4s both,
      caret 0.8s step-end infinite;
  }
  @keyframes type { from { width: 0; } }
  @keyframes caret { 50% { border-color: transparent; } }

  /* 3. TICKING HAND: 60 discrete positions per minute. */
  .dial {
    position: relative;
    width: 6rem;
    height: 6rem;
    border: 2px solid currentColor;
    border-radius: 50%;
  }
  .hand {
    position: absolute;
    left: calc(50% - 1px);
    top: 10%;
    width: 2px;
    height: 40%;
    background: #dc2626;
    transform-origin: 50% 100%;
    animation: tick 60s steps(60) infinite;
  }
  @keyframes tick { to { rotate: 1turn; } }

  @media (prefers-reduced-motion: reduce) {
    .sprite, .hand { animation: none; }
    .typed { animation: none; border-inline-end-color: transparent; }
  }
</style>
</head>
<body>
  <div class="sprite" role="img" aria-label="Walking character"></div>
  <p class="typed">Deploy finished in 42s.</p>
  <div class="dial" role="img" aria-label="Seconds dial"><span class="hand"></span></div>
</body>
</html>

Two lines deserve attention. The sprite animates to -384px, the position of a ninth frame that does not exist, and uses steps(8): with the default jump-end, the eight plateaus show frames one to eight and the ninth position is never displayed. Animating to -336px — the last real frame — would need steps(7), or steps(8, jump-none). And the typewriter's text, "Deploy finished in 42s.", is 23 characters including spaces and the full stop, so it animates width in 23 steps of 1ch; if the text changes length, the step count and final width must change with it, which is the main maintenance cost of the effect.

The key technique: matching steps to distance

Every steps() bug is a mismatch between the number of steps and the distance divided by the size of one frame. The rule is simple enough to state as arithmetic.

steps = distance ÷ frame size A strip of eight numbered frames, 48 pixels each. An arrow spans from frame one's start to the end of frame eight, 384 pixels, labelled steps(8) with jump-end. A dashed ninth slot marks the never-shown position. 384px ÷ 48px = 8 steps 1 2 3 4 5 6 7 8 never background-position: 0 → -384px, steps(8) Travel to the last frame instead (-336px)? Then steps(7), or steps(8, jump-none).

When a sprite looks like it "slides" between frames, the step count is too high and the sheet is being positioned between frames. When it skips a frame, the count is too low. When it shows a blank or a frame from another row at the end of each cycle, the distance goes one frame too far for the step position chosen. Checking the arithmetic — distance divided by frame size equals steps for jump-end — resolves all three.

Accessibility notes

Frame-by-frame effects are frequently decorative and frequently looping, which is the combination WCAG 2.2.2 Pause, Stop, Hide addresses: moving content that starts automatically and lasts more than five seconds needs a mechanism to pause it. A looping sprite in the corner of a page qualifies. The simplest compliant approach is to not loop indefinitely — animation-iteration-count: 3 — and to disable the animation under prefers-reduced-motion, as the example does.

The typewriter needs a second consideration: screen readers read the element's full text immediately, regardless of the visual animation, which is correct. Do not try to "type" content into the accessibility tree character by character with live regions; that produces a stream of announcements. And blinking carets should stop after a few seconds or respect reduced motion, since blinking content is itself a motion concern. The patterns for pausing loops are covered in Pause Controls for Looping Animations.

Variation: a segmented progress meter

steps() also works on transitions. A progress meter drawn as ten segments should fill segment by segment, not smoothly, so that the fill always lands on a segment boundary.

.meter {
  --value: 0.7;                          /* set from data, 0 to 1 */
  height: 0.75rem;
  border-radius: 999px;
  background:
    linear-gradient(#4f46e5 0 0) 0 / calc(var(--value) * 100%) 100% no-repeat,
    repeating-linear-gradient(to right, #e2e8f0 0 9%, transparent 9% 10%);
  transition: background-size 0.6s steps(10, jump-end);
}

@media (prefers-reduced-motion: reduce) {
  .meter { transition: none; }
}

Because the transition runs in ten steps of 10%, the fill advances one segment at a time, matching the drawn segments. The meter should still be a real <progress> or role="meter" element with a text value for assistive technology; the stepped fill is presentation only.

Browser support

steps() with jump-start and jump-end (and the older start and end keywords) is supported in every browser with CSS animations; @keyframes dates from Chrome 43, Edge 12, Firefox 16 and Safari 9. The jump-none and jump-both positions are newer but supported in all current engines. The standalone rotate property used for the clock hand is supported in current Chrome, Edge, Firefox and Safari; transform: rotate() is the fallback.

FAQ

What is the difference between steps(4, jump-start) and steps(4, jump-end)? Both divide the animation into four equal intervals and hold a constant value in each. jump-start changes value at the beginning of each interval, so the first frame is already one step in. jump-end changes at the end of each interval, so the first frame shows the starting value and the final value only appears as the animation finishes. jump-end is the default.

Why does my sprite animation skip the first or last frame? The step position does not match the keyframe distances. For a sprite sheet with N frames animated from the first frame's position to the position just past the last frame, use steps(N) with the default jump-end. Animating to the last frame's position instead needs steps(N - 1).

Can steps() be used with transitions as well as animations? Yes. steps() is a timing function like any other, so transition-timing-function: steps(5) makes a transition change in five discrete jumps. It is useful for pixel-art hovers and segmented progress bars.

Is a steps() animation cheaper than a smooth one? Not necessarily. The browser still produces a frame for every display refresh; it just holds the same value across many of them. Animating background-position repaints on each step change, while stepping transform or opacity stays on the compositor.

Related articles

More pages in the same section.