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-functionshapes a transition from its start value to its end value. One curve, applied once, across the whole transition.animation-timing-functionapplies 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-outon a three-keyframe animation doesn’t produce one smooth S-curve, it produces two. (You can override the easing per keyframe by declaringanimation-timing-functioninside 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:
| Keyword | Equivalent | Character |
|---|---|---|
linear | cubic-bezier(0, 0, 1, 1) | Constant speed, no easing at all |
ease | cubic-bezier(0.25, 0.1, 0.25, 1) | Fast start, long gentle landing — the default |
ease-in | cubic-bezier(0.42, 0, 1, 1) | Slow start, abrupt end |
ease-out | cubic-bezier(0, 0, 0.58, 1) | Fast start, slow settle |
ease-in-out | cubic-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.
y1ory2below 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. The1.56in 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 immediately — ease-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:
| Value | Behavior | Steps shown for steps(4, …) |
|---|---|---|
jump-end (alias end) | Jump at the end of each interval — holds the start value first | 4 |
jump-start (alias start) | Jump at the start — snaps immediately | 4 |
jump-none | No jump at either boundary; both endpoints get dwell time | 3 |
jump-both | Jumps at both boundaries | 5 |
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 exit | ease-in |
| A move between two on-screen positions | ease-in-out |
| A seamless loop (marquee, spinner) | linear |
| Typing, counters, sprite frames | steps() |
| A bounce or spring | linear() 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.