Keyframe
Keyframe · The log

CSS Animation Timing Function: ease, cubic-bezier, and steps()

July 18, 2026 · Uncategorized

Two animations can share the exact same duration, the exact same start and end, and feel completely different. One is cheap and mechanical. The other feels like it has weight. The difference is the timing function, and it is the single property that decides whether your motion looks designed or looks like a stopwatch.

Most people set duration and never touch this. That is the mistake.

What the animation timing function does

animation-timing-function shapes the rate of change over the run. Duration says how long. The timing function says how the motion is distributed inside that time: front-loaded, back-loaded, even, or something with a kick at the end.

Picture a box sliding 300px to the right over one second. With a linear function it covers 300px at a constant pace, same speed the whole way. With an ease-out function it lunges off the line and glides to a stop. Identical distance, identical time. What changed is the acceleration, and acceleration is what your eye reads as physical.

One detail that trips people up early: the timing function applies to each interval between keyframes, not to the animation as a whole. If your @keyframes has stops at 0%, 50%, and 100%, the easing runs fresh from 0 to 50, then again from 50 to 100. You can also override it inside a single keyframe to change the feel of just one leg. Good to know before you wonder why a three-stop animation “pulses” oddly in the middle.

And the default is not linear. If you never set a timing function, you get ease, which is already curved. That is why beginner animations often look vaguely soft-and-slow without anyone choosing it.

ease vs linear vs the ease-in family

There are five keywords worth knowing cold. Here is what each one does and where it belongs.

Commit one rule to memory and your UI motion improves immediately: enter with ease-out, leave with ease-in. Things coming toward the user decelerate into place. Things leaving accelerate away. Get those two right and half your animations already feel correct.

These curves carry effects, too. The gradient shine and reveal timings in CSS text animation live or die on picking ease-out over the default, and a lazy ease-in-out is what makes a blob animation feel like it is breathing rather than ticking.

Reading a cubic-bezier curve

Every keyword above is just a named cubic-bezier(). Learn to read the raw function and you stop being limited to the five presets. cubic-bezier() defines a custom acceleration curve, and it takes four numbers.

animation-timing-function: cubic-bezier(x1, y1, x2, y2);

The curve is drawn on a little graph. Time runs left to right along the x axis, 0 at the start of the animation, 1 at the end. Progress runs bottom to top on the y axis, 0 is the start value, 1 is the end value. The curve always begins at the bottom-left corner (0,0) and ends at the top-right (1,1). You cannot move those anchors. What the four numbers control are two handles that bend the line between them.

Now the reading trick. Where the curve is steep, a lot of progress happens in a little time, so the element moves fast. Where the curve is flat, time passes but progress barely changes, so it moves slow. A curve that starts flat and ends steep is an ease-in: slow, then fast. Flat at the top, steep at the bottom, is an ease-out.

Two hard limits. The x values are clamped to the 0 to 1 range, because time cannot run backward. The y values are not clamped at all, and that freedom is where the fun starts.

Building an overshoot spring

Push a y value above 1 and the curve climbs past the finish line before coming back down to it. In motion, that means the element travels past its target and snaps back. Overshoot. It is the difference between a value that arrives and a value that lands.

.pop {
  animation: pop 320ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
}

@keyframes pop {
  from { transform: scale(0.6); }
  to   { transform: scale(1); }
}

That 1.56 is the y1 value poking above 1. The scale runs to about 1.05 before easing back to 1, giving a small confident bounce as it settles. This is the exact feel you want on a badge popping in, a modal appearing, or the circle behind a success checkmark. Springy, brief, done.

A cubic-bezier can only overshoot at one end, though. It is a single smooth curve, so you get one bounce, not a decaying wobble. For a real multi-bounce spring, there is a newer tool: the linear() easing function. It takes a list of progress points and draws straight segments between them, which lets you fake the up-down-up-down of a spring settling.

.bounce {
  animation: pop 500ms linear(
    0, 0.4, 0.9, 1.05, 1.02, 0.99, 1
  ) forwards;
}

Each number is a progress value at an even slice of time. Notice the list overshoots past 1 (to 1.05) then dips under (0.99) then settles at 1. That is a bounce you cannot draw with a single cubic-bezier. You do not hand-write these numbers, by the way. Generators exist that turn spring physics into a linear() string. Support is Baseline since late 2023, so Chrome, Safari, and Firefox all handle it in current versions, with a plain ease-out as a sensible fallback for anything older.

steps() for ticks and sprites

Every function so far is smooth. steps() is the opposite on purpose. It advances in discrete jumps instead of gliding, holding a value flat and then snapping to the next. No interpolation, just clicks.

Two jobs need this. The first is anything that should tick rather than slide: a countdown digit going 3, 2, 1, or a clock hand that jerks per second. The CSS countdown animation ticking-number trick is built entirely on steps(), because a countdown that smoothly slides through 2.4 is not a countdown.

The second job is sprite-sheet animation. Line up frames in one long image, show one frame’s width at a time, and step the background across them. Old-school, still perfect for pixel art and tiny looping characters.

.sprite {
  width: 64px;
  height: 64px;
  background: url(walk.png) 0 0 / auto 100%;
  animation: walk 700ms steps(8) infinite;
}

@keyframes walk {
  to { background-position-x: -512px; } /* 8 frames × 64px */
}

Eight frames, steps(8), and the background jumps one 64px frame at a time. Slide it smoothly and you would see the frames smear together. Step it and each frame sits still long enough to read.

The one setting that causes support tickets is the jump direction. steps(n) defaults to steps(n, jump-end), which jumps at the end of each interval, so it shows the starting value first and reaches the final value only at the very end. steps(n, jump-start) jumps at the start of each interval, so it skips the starting value and shows the final one for the last slice. For a sprite walk cycle, jump-end usually looks right. For a countdown that must sit on its first number immediately, the choice actually changes what the user sees on frame one, so try both and watch.

Pick the curve on purpose

Short version to keep at your desk. Spinners get linear. Things entering get ease-out, things leaving get ease-in. A pop or a badge wants a cubic-bezier with a y above 1. A real spring wants linear(). Anything that ticks or flips frames wants steps().

The timing function is the smallest property with the biggest payoff in the whole CSS animation toolkit. Duration is the boring number. This is the one that carries the feel. Set it deliberately and your motion stops looking like a demo.

Related

← Back to the log