toolhq.io

All posts
5 min readby Jameel Haider

The CSS steps() easing function explained: jump-start, jump-end, and sprite animation

Most CSS easing functions produce smooth motion. steps() does the opposite on purpose: it freezes on a value, then jumps to the next, with nothing in between. That discontinuity is exactly what you want for a sprite sheet, a ticking clock, or a typewriter effect, and it is the one case where a Bezier curve is the wrong tool.

What steps() does

A normal timing function maps elapsed time to a continuous progress value between 0 and 1. steps(n) instead divides that progress into n equal chunks and holds each chunk flat, snapping to the next at the boundary. The animation's duration is unchanged; only the sampling is quantized.

animation: move 1s steps(4) forwards;

Over one second this produces four discrete states, each held for 250ms, with instant jumps between them. No tweening, no blur of intermediate values.

jump-start vs jump-end

The detail that confuses people is where the jumps land. steps() takes an optional second keyword controlling whether the first jump happens at the start of the animation or whether the last one happens at the end.

SyntaxJumps at start?Jumps at end?Distinct values shown
steps(4, jump-start)yesno4
steps(4, jump-end)noyes4
steps(4, jump-both)yesyes5
steps(4, jump-none)nono3

The default when you omit the keyword is jump-end, which means the animation holds the start value first, then steps through. jump-start instead jumps immediately off the start value.

Two older aliases still work: step-start equals steps(1, jump-start) and step-end equals steps(1, jump-end). Both give you a single instant switch, useful for toggling a property at the midpoint of a transition.

The jump-none variant is the one to reach for when you have a fixed set of frames and want each shown for an equal slice of time, including both the first and last, with no duplicate. That is n steps producing n values across the full range.

Sprite-sheet animation

The classic use is animating a sprite sheet: one image holding a row of frames, revealed by shifting background-position.

.sprite {
  width: 64px;
  height: 64px;
  background: url(walk.png) 0 0;
  animation: walk 0.6s steps(8) infinite;
}
@keyframes walk {
  to { background-position: -512px 0; }
}

Here the sheet is eight frames of 64px, so 512px wide. steps(8) snaps the background through exactly eight positions, showing each frame cleanly with no half-frame bleed. With a smooth easing you would see two frames sliding past each other, which looks broken. Match the step count to the frame count and the end position to the full strip width, and it reads as crisp animation.

The same pattern drives a typewriter effect: animate a text element's width from 0 to its full character width with steps(nCharacters) and a monospace font, and characters appear one at a time.

When to prefer it over cubic-bezier

Reach for steps() when the thing being animated is genuinely discrete:

  • Sprite frames, where intermediate pixel positions are meaningless.
  • A counter or clock that should tick, not glide.
  • Any effect where you want a deliberate, mechanical feel rather than organic motion.

Reach for a Bezier curve, which is what ease, ease-in-out, and custom cubic-bezier() all are, when the motion represents something physical: a panel sliding, a button settling, a value smoothly growing. Those want acceleration and deceleration, not jumps.

A quick gut check: if you would describe the motion as "snapping" or "ticking," use steps(). If you would describe it as "gliding" or "settling," use a curve.

To design the smooth-motion half of that choice, tune the acceleration visually with the Cubic Bezier Generator and reserve steps() for the frames that genuinely belong on a grid.