🍱 Lunchbox Hands

css

CSS Easing Functions Explained: ease, cubic-bezier(), steps(), and linear()

What an easing function actually is, the cubic-bezier values behind the keywords, why ease-out belongs on entrances, how steps() powers typing effects, and what linear() can do that cubic-bezier cannot.

Every CSS transition and animation answers the same question over and over: given that some fraction of the duration has elapsed, how far along should the animated value be? An easing function is nothing more than the curve that answers that question — input is elapsed time from 0 to 1, output is progress from 0 to 1 — and once you read it that way, every keyword and every cubic-bezier() you’ve ever copy-pasted becomes legible.

Most easing advice is vibes (“ease-out feels snappy”). The vibes are correct, but there’s a mechanical reason behind each one, and knowing the mechanism is what lets you pick an easing deliberately instead of cycling through keywords until one looks right.

Where easing functions plug in

Two properties accept them, and they behave slightly differently:

  • transition-timing-function shapes a transition from its start value to its end value. One curve, applied once, across the whole transition.
  • animation-timing-function applies per keyframe segment, not across the whole animation. An animation with keyframes at 0%, 50%, and 100% runs the easing twice — once per segment. This surprises almost everyone the first time: ease-in-out on a three-keyframe animation doesn’t produce one smooth S-curve, it produces two. (You can override the easing per keyframe by declaring animation-timing-function inside the keyframe block itself.)

The default for both is ease — not linear. If you’ve never set a timing function, everything you’ve animated has been slightly front-loaded this whole time.

The keywords, and what they actually are

The five keywords are shorthands for specific curves. Four of them are fixed cubic-bezier() values defined in the CSS Easing spec:

KeywordEquivalentCharacter
linearcubic-bezier(0, 0, 1, 1)Constant speed, no easing at all
easecubic-bezier(0.25, 0.1, 0.25, 1)Fast start, long gentle landing — the default
ease-incubic-bezier(0.42, 0, 1, 1)Slow start, abrupt end
ease-outcubic-bezier(0, 0, 0.58, 1)Fast start, slow settle
ease-in-outcubic-bezier(0.42, 0, 0.58, 1)Slow both ends, fast middle

Two things worth noticing in that table. First, ease is not the same as ease-in-out — it’s asymmetric, accelerating harder at the start than it decelerates at the end. Second, ease-in and ease-out are mirror images of each other (0.42 on one axis becomes 0.58 on the other), which is why an enter animation using ease-out and an exit using ease-in feel like a matched pair.

How cubic-bezier() actually works

transition: transform 300ms cubic-bezier(0.34, 1.56, 0.64, 1);

A cubic Bézier easing curve is anchored at (0, 0) and (1, 1) — the animation starts at zero progress and ends at full progress, no negotiation. The four numbers you supply are the two control points in between: cubic-bezier(x1, y1, x2, y2). The first control point shapes how the curve leaves the start; the second shapes how it approaches the end. Control points pull the curve toward themselves like magnets — the curve doesn’t pass through them, it bends toward them.

The axes are the whole story:

  • x is time. It must stay within [0, 1], and the spec enforces this — a curve where time runs backwards or where one moment maps to two different progress values isn’t a function anymore. cubic-bezier(1.2, 0, 0.5, 1) is simply invalid.
  • y is progress. It is not clamped. y1 or y2 below 0 means the value briefly moves backwards before going forward — anticipation, a wind-up. Above 1 means the value overshoots its target and settles back — the pop that makes a modal or button feel physical. The 1.56 in the example above is exactly that: the element travels past its destination and comes back.

Reading four decimals in your head never becomes intuitive, though — this is a case where you want to see the curve while you drag it. Our cubic-bezier() editor does exactly that: drag the two control points, watch a live animation preview run the curve, and copy the value when the motion feels right.

The rule of thumb that covers 90% of cases

Entrances get ease-out. Exits get ease-in. On-screen moves get ease-in-out.

The reasoning is perceptual, not aesthetic. An entering element should command attention immediatelyease-out moves fast in the first frames, so the user registers “something is arriving” right away, then the deceleration reads as the element settling into place the way a physical object would. An exiting element is the opposite: the user has already dismissed it mentally, so it can start slow and accelerate away — ease-in — because nobody needs to track where it lands. Offscreen is offscreen.

The common mistake is ease-in on an entrance. The element spends its first frames barely moving — precisely when the user is trying to figure out what’s happening — then slams to a stop. It reads as sluggish and abrupt, which is impressive in the wrong way.

For hover states and micro-interactions, short durations flatten these distinctions — at 150ms almost any reasonable curve reads fine. Where the curve choice becomes visible is 250ms and up. If you want to feel the difference concretely, our button hover effects and keyframe animation generator both let you tweak timing on live previews — the animation generator’s 20 presets are a decent tour of which curves suit which motion types.

steps(): easing without the smoothness

steps() throws away interpolation entirely. Instead of a curve, progress becomes a staircase: the value holds still, jumps, holds still, jumps.

animation: type 3s steps(20) forwards;

The second argument controls where the jumps land relative to the intervals:

ValueBehaviorSteps shown for steps(4, …)
jump-end (alias end)Jump at the end of each interval — holds the start value first4
jump-start (alias start)Jump at the start — snaps immediately4
jump-noneNo jump at either boundary; both endpoints get dwell time3
jump-bothJumps at both boundaries5

The default is jump-end, which is what you want almost every time. There are also two keyword shorthands: step-start is steps(1, jump-start) and step-end is steps(1, jump-end).

Two effects are essentially made of steps():

Typewriter text. Animate a container’s width from 0 to 100% over the text with steps(n) where n is the character count, and the reveal advances one character-width at a time instead of sliding smoothly. Pair it with a cursor blinking via step-end — a smooth opacity fade reads as a glow; a stepped one reads as a terminal cursor. This is exactly how our CSS typing effect generator builds its output: the reveal is steps(n) matched to your text length, and the cursor blink is a separate step-end animation.

Sprite sheets. Animate background-position across a film strip of frames with steps(frameCount), and each step lands cleanly on one frame. With a smooth curve you’d see the strip sliding between frames — the steps are what make it animation rather than smearing.

linear(): the easing cubic-bezier can’t express

A single cubic Bézier is one curve segment. It can overshoot once and settle — but it cannot oscillate. A bounce hits the floor three or four times; a spring overshoots, comes back, overshoots less, comes back less. No choice of four numbers produces that, because the math has one bend budget and a bounce needs several.

linear() (the function, distinct from the linear keyword) solves this by brute force: you supply a list of progress values, optionally pinned to timestamps, and the browser connects them with straight line segments. Enough points, and the polyline approximates any curve you like:

/* A bounce, as a piecewise-linear approximation */
animation-timing-function: linear(
  0, 0.063, 0.25, 0.563, 1 36.4%,
  0.812, 0.75 54.5%, 0.813, 1 72.7%,
  0.953, 0.938, 0.953, 1 90.9%,
  0.984, 1
);

Each entry is a progress value; the percentages pin specific entries to points in time, and unpinned values are spaced evenly between their pinned neighbors. Values above 1 and below 0 are allowed, same as cubic-bezier()’s y-coordinates. Nobody writes these by hand — the workflow is to model a spring in JavaScript or a tool, sample it at 10–20 points, and paste the result. What you get in exchange is spring physics in pure CSS, no animation library shipped to the client.

On support: linear() landed in Firefox 112, Chrome 113, and Edge 113 (all spring 2023) and Safari 17.2 (December 2023), and reached Baseline Widely Available in June 2026. In 2026 you can use it without hesitation for anything new; if your audience skews toward very old installs, note that an unsupported browser rejects the whole declaration, so a plain animation-timing-function: ease-out; line before the linear() one is a free fallback.

One clarification that trips people up: the humble linear keyword is still the right tool for constant-speed motion, and constant speed is correct more often than easing enthusiasts admit — anything that loops seamlessly must move linearly, or the loop point becomes a visible lurch. Scrolling tickers are the canonical case, which is why every seamless preset in our CSS marquee generator uses linear timing. Spinners and infinite rotations, same rule.

A note on prefers-reduced-motion

Whatever curve you pick, wrap significant motion in a media query. Vestibular disorders make large translations, zooms, and parallax physically unpleasant for a real fraction of users, and the fix is one block: inside @media (prefers-reduced-motion: reduce), drop the animation or replace the movement with a plain opacity fade. Easing choice is a refinement; respecting the OS-level “less motion, please” setting is table stakes.

Picking in practice

You’re animating…Reach for
An entrance (modal, toast, menu)ease-out, or a slight-overshoot cubic-bezier()
An exitease-in
A move between two on-screen positionsease-in-out
A seamless loop (marquee, spinner)linear
Typing, counters, sprite framessteps()
A bounce or springlinear() with sampled points

When the keywords aren’t cutting it, the fastest route to a good custom curve is visual: drag one out in the cubic-bezier() editor, or start from a preset in the keyframe animation generator and adjust from there. Both run entirely in your browser and give you copy-paste CSS.

For neighboring topics, see CSS clip-path and how animated GIFs work — the latter being what steps() sprite animation quietly replaced.