Under the Hood
Animation

Easing: cubic-bézier, steps, and the shape of time

Easing has nothing to do with decoration — it's a pure function that remaps time itself, and once you see a timing function as "progress = f(t)" rather than a vague vibe like "ease-out," every named curve, every bounce, and every sprite-sheet animation becomes something you can derive instead of look up.

Easing: cubic-bézier, steps, and the shape of time

The previous lesson built the entire mechanism of CSS animation on one formula: value(t) = start + (end - start) × progress(t). It then quietly assumed the simplest possible version of progress(t) — that progress advances exactly as fast as time does, so that 50% of the duration means progress = 0.5. That assumption is linear timing, and it's almost never what you actually want. This lesson is about the function that replaces it: easing.

Why linear motion looks wrong

Set transition-timing-function: linear on a box moving 300px over one second, and it covers exactly 75px every 250ms, for the entire second, then stops dead. Watch it and something reads as off before you can articulate why: the box is moving at constant velocity the whole time, and then instantaneously drops to zero velocity the moment the duration ends. Nothing in the physical world behaves like that. A car doesn't jump to cruising speed the instant you touch the accelerator and doesn't stop the instant you release it; a hand reaching for a light switch accelerates into the motion and decelerates before contact. Real motion has velocity that changes continuously — it ramps up, it ramps down, sometimes it overshoots and settles back. Linear timing has none of that, which is exactly why it reads as mechanical rather than natural: it's the one curve that contains no acceleration at all.

Easing exists to put that acceleration back in, and the mechanism for doing it is simpler than the visual result suggests: a timing function is nothing more than a pure function from t to progress.

A timing function is progress = f(t)

Formally: t is the linear fraction of the transition or animation elapsed, from 0 (the start) to 1 (the end) — this is just wall-clock elapsed-time-over-duration, the same t from the previous lesson. A timing function f takes that t and produces the progress value that actually gets fed into the interpolation formula:

progress(t) = f(t)
value(t)    = start + (end - start) × progress(t)

linear is the trivial case: f(t) = t, so progress tracks elapsed time exactly. Every other named timing function — ease, ease-in, ease-out, ease-in-out, or a cubic-bezier() you write yourself — is a different choice of f, one where f(t) is not equal to t for most values of t in between. The interpolation math downstream never changes; only the progress value it's fed does. This is the whole trick: easing doesn't touch what gets interpolated, it changes when, during the timeline, each amount of interpolation happens.

Because f is evaluated fresh every frame — the browser doesn't cache a table of positions, it computes f(t) for whatever t the current frame corresponds to — the shape of f directly controls velocity. Velocity is the rate of change of progress with respect to t, which graphically is the slope of the curve at that point. Where the curve is steep, a small step in time produces a large step in progress — the object is moving fast. Where the curve is flat, a small step in time produces almost no change in progress — the object is moving slow, or momentarily still. An easing curve is, quite literally, a plot of "how fast is this animation moving" at every point in its duration.

cubic-bezier(): four numbers that define a velocity curve

cubic-bezier(x1, y1, x2, y2) is how CSS lets you specify f directly. It's a cubic Bézier curve — the same mathematical object used in vector drawing tools — with four control points, but two of them are fixed by the problem itself: the curve must start at (0, 0) (at t=0, progress must be 0) and end at (1, 1) (at t=1, progress must be 1). Those are non-negotiable; an easing function that didn't start at 0 and end at 1 wouldn't produce a transition that actually reaches its target value. The two points you supply, (x1, y1) and (x2, y2), are the curve's control points — they don't sit on the curve, they pull it, the way handles on a vector-drawing tool's anchor point bend a path without being on the path itself.

P0 = (0, 0)         fixed: start of time, start of progress
P1 = (x1, y1)        you supply this — pulls the early part of the curve
P2 = (x2, y2)        you supply this — pulls the late part of the curve
P3 = (1, 1)         fixed: end of time, end of progress

The shape those two points pull the curve into is a direct, readable encoding of velocity over time:

/* ease-in: starts slow, accelerates into the motion */
transition-timing-function: cubic-bezier(0.42, 0, 1, 1);

/* ease-out: starts fast, decelerates into the landing */
transition-timing-function: cubic-bezier(0, 0, 0.58, 1);

/* ease-in-out: slow, fast, slow again — the S-curve */
transition-timing-function: cubic-bezier(0.42, 0, 0.58, 1);

Read ease-in's control points mechanically: P1 = (0.42, 0) sits low — its y-value is still 0 even though its x-value (time) is already 0.42 — which drags the early part of the curve flat against the bottom, meaning progress barely moves while time is already well underway. That flat region is the "slow start." P2 = (1, 1) coincides with the endpoint, so nothing pulls the tail down, and the curve has to steepen sharply to close the distance from wherever it was to (1,1) — that steep final stretch is the acceleration you feel as the motion "catches up." ease-out is the mirror image: P1 near the origin (an immediately steep departure — fast start) and P2 pulled low relative to where it needs to end up, flattening the curve into the landing. ease-in-out combines both moves, which is why it reads as a full S: slow away from the start, fast through the middle, slow into the finish.

The named keywords are not a separate mechanism from cubic-bezier() — they're just presets, shorthand for specific, fixed control-point values baked into the spec. ease itself, the default for every CSS transition when you don't specify a timing function, is cubic-bezier(0.25, 0.1, 0.25, 1). Once you can read control points as "where does the curve get pulled flat, and where does it get pulled steep," you no longer need to memorize what ease-in-out looks like — you can sight-read it off the four numbers.

Why the curve is allowed to leave [0, 1]

Here's the detail that trips people up the first time they see it: x1 and x2 are constrained to [0, 1] (time only runs forward — a valid easing function has to be a proper function of t, never doubling back), but y1 and y2 have no such constraint. A control point's y-value — which pulls progress, not time — is allowed to go below 0 or above 1.

That's not a quirk, it's the entire mechanism behind overshoot and anticipation effects. If the curve's progress value exceeds 1 at some point before settling back to 1 at the end, then plugging that progress into value(t) = start + (end - start) × progress(t) produces a value that is temporarily past the target — the element overshoots where it's headed, then eases back to the correct final position. That's the "pop past, then settle" bounce you see in playful UI (a button that grows slightly larger than its final size before relaxing back, a panel that slides slightly past its resting position and eases back into place). The reverse — a control point pulling progress below 0 early in the curve — produces anticipation: the element pulls back slightly in the wrong direction before committing to the real motion, the animation equivalent of winding up before a throw.

/* Overshoot: progress briefly exceeds 1, so value briefly exceeds `end` */
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);

This is worth sitting with because it demonstrates exactly how mechanical the whole system is: nothing about the interpolation formula from the previous lesson changed, and nothing special was added to support "bounce." A y-value above 1 is a legal number, the formula doesn't clamp it, and an overshoot falls straight out of feeding that number through unchanged.

steps(): discrete jumps instead of a continuous curve

cubic-bezier() describes a smooth, continuous f. steps(n, jumpterm) describes the opposite: an f that holds a constant progress value, then jumps instantly to the next one, n times across the duration. There's no blending between the n positions — each one is a hard, discrete value, held for 1/n of the total duration before the next jump.

The jumpterm (jump-start, jump-end, jump-both, or jump-none) decides which side of each interval the jump happens on — whether the value changes at the very start of each interval or the very end of it — which mostly matters for exactly where a step-start or step-end transition seems to "begin" visually. step-start and step-end are just shorthand for steps(1, jump-start) and steps(1, jump-end) — a single discrete jump partway through (or at the end of) the duration, no intermediate steps at all.

The canonical real use for steps() is sprite-sheet animation, where you have a single image strip of, say, 8 frames of a walk cycle laid out side by side, and you want to flip through them at fixed intervals rather than sliding smoothly between them:

/* An 8-frame, 800px-wide sprite sheet (100px per frame), stepped */
.walk-cycle {
  width: 100px;
  height: 100px;
  background-image: url("walk-sheet.png");
  animation: walk 0.8s steps(8, jump-none) infinite;
}
@keyframes walk {
  from { background-position-x: 0; }
  to   { background-position-x: -800px; }
}

Without steps(8, ...), the browser would interpolate background-position-x continuously, smearing between frames of the sprite sheet as if it were sliding a window across the image — visually wrong, since each of the 8 frames is a discrete, complete drawing, not a continuum. steps(8, ...) forces the interpolation to snap directly between the 8 valid positions, holding each one for exactly 0.1s, which is what actually makes it read as a walk cycle rather than a smear.

Duration and easing are independent axes

It's worth being explicit about something the formula already implies but that's easy to blur in practice: transition-duration and transition-timing-function are two completely independent knobs. Duration says how much total wall-clock time t takes to go from 0 to 1. Easing says what shape progress takes as a function of that t. Doubling the duration doesn't change the curve's shape at all — it stretches the same curve over twice the time, so every velocity in the curve is halved, but the proportions of slow-start, fast-middle, slow-end stay identical. Changing the easing without touching the duration keeps the total time to completion the same but completely redistributes where, within that fixed window, the motion is fast or slow. Treat them as orthogonal design decisions: duration is "how long," easing is "what does that time feel like."

What a cubic-bezier can't express: springs

Every curve discussed so far — linear, the named eases, any cubic-bezier() you hand-tune — is a fixed function of t, decided once, up front, before the animation starts. That's fine as long as nothing about the animation needs to change mid-flight. But a spring-based animation (the kind you get from a physics engine, or from libraries like GSAP or Framer Motion's spring presets) is fundamentally different: it's not a predetermined curve at all, it's the output of simulating a mass on a spring with some stiffness and damping, frame by frame, in response to wherever the object currently is and how fast it's currently moving.

That distinction matters because a spring is stateful and reactive in a way a Bézier curve structurally cannot be. If you interrupt a cubic-bezier() transition halfway through and retarget it — the mechanism the interpolation lesson described, capturing a new start and end — the browser starts a brand new fixed curve from the current value, with no memory of how fast the element was moving the instant before. A spring, by contrast, carries velocity as real state: if you redirect a spring-driven animation mid-motion, the simulation's next step naturally accounts for however fast the object was already moving, producing a continuous, physically plausible redirect instead of a curve restarting from a standstill. No four fixed control points can encode "and also remember the current velocity and react to it every frame" — that requires actually re-running the physics simulation on each frame, which is exactly why spring animations are computed by JavaScript frame by frame rather than expressed as a cubic-bezier() value. It's a preview of a tension this module returns to directly once GSAP and JS-driven animation enter the picture: CSS's timing functions are fast and free because they're fixed math the browser can precompute; springs are expressive and interruptible because they're a live simulation, and a live simulation has to run somewhere, every frame.

Where this goes next

Timing functions decide the shape of a single frame's progress value, but they say nothing about where that computation happens. A transform animation eased with a perfect cubic-bezier() curve is still only as smooth as the thread computing it lets it be — and if that thread is the main thread, busy with your JavaScript, layout, and paint, the easing curve is irrelevant to a dropped frame. The next lesson covers the mechanism that makes certain animations immune to exactly that problem: running them on a separate compositor thread, off the main thread entirely.

Go deeper

  • MDN — easing-function The full syntax reference for cubic-bezier(), steps(), and the linear() function, including every named keyword's exact control-point values.
  • W3C — CSS Easing Functions Level 1 The formal spec defining timing functions as t -> progress mappings, including the exact rules for how steps() and its jumpterms behave at interval boundaries.
  • Chrome DevTools — Edit animations and CSS transitions Where the interactive cubic-bezier curve editor mentioned in this lesson actually lives, so you can drag control points and watch the curve and motion respond together.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Write the two-line relationship between t, progress(t), and value(t), and explain which one a timing function actually modifies.
  2. Why does linear timing look 'mechanical' in terms of velocity, when real-world motion never looks like that?
  3. Sketch (in words) why ease-in's control point (0.42, 0) produces a slow start, and why its second point coinciding with (1,1) produces a fast finish.
  4. Why are a cubic-bezier's x-values constrained to [0,1] while its y-values are not, and what visual effect does a y-value above 1 produce?
  5. Explain why steps(8, jump-none) is the correct choice for an 8-frame sprite-sheet walk cycle, and what would go wrong with a smooth cubic-bezier instead.
  6. Are duration and easing the same axis or independent axes? If you double the duration of an eased transition, does the shape of its velocity curve change?
  7. Why can't a spring-based animation be expressed as a fixed cubic-bezier(), and what does that force JS animation libraries to do instead?