What the four numbers mean
An easing curve maps time to progress. Both run from 0 to 1: across is the fraction of the duration gone, up is the fraction of the way from the start value to the end value. cubic-bezier() draws that curve as a cubic Bézier with four points. The first, P0, is fixed at (0, 0) and the last, P3, at (1, 1). The four numbers place the two in between:
cubic-bezier(x1, y1, x2, y2)
└─ P1 ─┘ └─ P2 ─┘
P0 = (0, 0) the start, fixed
P3 = (1, 1) the end, fixed
x = time x1 and x2 must be between 0 and 1
y = progress y1 and y2 can be any numberP1 and P2 are handles, not points on the curve. The curve leaves the start heading towards P1 and arrives at the end coming from the direction of P2, and the further a handle sits from its end, the harder it pulls. Fig. 1 draws six curves straight from their numbers. Use one anywhere CSS takes an easing:
.card {
/* The same curve as the keyword ease, written out. */
transition: translate 400ms cubic-bezier(0.25, 0.1, 0.25, 1);
}
.card:hover {
translate: 0 -8px;
}The x values are time, and time cannot run backwards, so x1 and x2 must be between 0 and 1. Anything else makes the whole declaration invalid, and the browser keeps whatever easing came before it. The y values have no limit; that is how a curve overshoots.
.panel {
transition-timing-function: ease-out;
/* x2 is 1.2, outside 0–1: the browser drops this line, ease-out stays. */
transition-timing-function: cubic-bezier(0.2, 0, 1.2, 1);
}ease, ease-in, ease-out and ease-in-out, exactly
The easing keywords are cubic-béziers with fixed numbers, set by the CSS Easing Functions spec. Writing the numbers out gives exactly the same curve as the keyword. ease is the default for both transition-timing-function and animation-timing-function, so a transition with no easing named uses it.
| Keyword | Is exactly | Progress at half time |
|---|---|---|
| ease | cubic-bezier(0.25, 0.1, 0.25, 1) | 0.802 |
| ease-in | cubic-bezier(0.42, 0, 1, 1) | 0.315 |
| ease-out | cubic-bezier(0, 0, 0.58, 1) | 0.685 |
| ease-in-out | cubic-bezier(0.42, 0, 0.58, 1) | 0.5 |
| linear | cubic-bezier(0, 0, 1, 1) | 0.5 |
ease-in-out is symmetric, so it is exactly half way at half time. ease is not: it is already 0.802 of the way there at half time, and spends the second half of the duration on the last 20% of the distance. That is why it feels quick and soft at once, and why it is a reasonable default for small UI changes.
Read a curve: the slope is the speed
The steepness of the curve at any moment is how fast the value is changing then, compared with linear, whose slope is 1 throughout. Flat means still; steeper than the diagonal means faster than linear. The curve leaves the start heading straight for P1, so P1 sets the starting speed, and P2 sets the final one the same way. When a handle sits on its own end, as P1 does in ease-out, the curve heads for the other handle instead.
| Keyword | Speed at the start | Speed at the end |
|---|---|---|
| ease | 0.4 × linear | 0 × linear |
| ease-in | 0 × linear | 1.72 × linear |
| ease-out | 1.72 × linear | 0 × linear |
| ease-in-out | 0 × linear | 0 × linear |
| linear | 1 × linear | 1 × linear |
So ease-out leaves at 1.72 times linear’s speed and arrives at rest: good for things coming into view, which should react at once and settle. ease-in is the reverse, still at the start and fastest as it hits the end, which suits things leaving. ease-in-out is still at both ends, for something moving from one resting place to another. To turn any curve around, so an in becomes an out, swap the handles and subtract every number from 1: cubic-bezier(1 − x2, 1 − y2, 1 − x1, 1 − y1).
Curves to copy
The usual names for curves, easeOutCubic and the rest, come from Robert Penner’s easing equations. These are the cubic-bezier values commonly published for them; each cell goes inside cubic-bezier( ). From Sine down to Expo each family is steeper than the one before, so its Out curve does more of the moving early and spends longer settling: a quarter of the way through, easeOutSine has covered 38% of the distance and easeOutExpo 83%.
| Family | In | Out | In-out |
|---|---|---|---|
| Sine | 0.12, 0, 0.39, 0 | 0.61, 1, 0.88, 1 | 0.37, 0, 0.63, 1 |
| Quad | 0.11, 0, 0.5, 0 | 0.5, 1, 0.89, 1 | 0.45, 0, 0.55, 1 |
| Cubic | 0.32, 0, 0.67, 0 | 0.33, 1, 0.68, 1 | 0.65, 0, 0.35, 1 |
| Quart | 0.5, 0, 0.75, 0 | 0.25, 1, 0.5, 1 | 0.76, 0, 0.24, 1 |
| Quint | 0.64, 0, 0.78, 0 | 0.22, 1, 0.36, 1 | 0.83, 0, 0.17, 1 |
| Expo | 0.7, 0, 0.84, 0 | 0.16, 1, 0.3, 1 | 0.87, 0, 0.13, 1 |
| Circ | 0.55, 0, 1, 0.45 | 0, 0.55, 0.45, 1 | 0.85, 0, 0.15, 1 |
| Back | 0.36, 0, 0.66, -0.56 | 0.34, 1.56, 0.64, 1 | 0.68, -0.6, 0.32, 1.6 |
Most of these are close copies of their equation, not the equation itself. Where the equation is a cubic polynomial, a cubic-bezier with its x handles at ⅓ and ⅔ is it exactly: easeOutCubic, which is 1 − (1 − t)³, is exactly cubic-bezier(0.333333, 1, 0.666667, 1). The generator gives the exact form wherever there is one.
Overshoot and back curves
Put a y outside 0 to 1 and the motion leaves the range between its start and end values. A y above 1 runs past the end and comes back; a y below 0 pulls back before it sets off. These are the Back curves:
| Name | cubic-bezier | Lowest | Highest |
|---|---|---|---|
| easeInBack | 0.36, 0, 0.66, -0.56 | -0.098 at 0.43 | 1 at 1 |
| easeOutBack | 0.34, 1.56, 0.64, 1 | 0 at 0 | 1.098 at 0.57 |
| easeInOutBack | 0.68, -0.6, 0.32, 1.6 | -0.105 at 0.21 | 1.105 at 0.79 |
.toast {
translate: 0 120%;
/* easeOutBack: runs past the end, then settles. */
transition: translate 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.toast.is-open {
translate: 0 0;
}Overshoot works on anything with room past its end: a translate, a scale, a width. Opacity is clamped to 0–1, and colour channels to their range, so on those an overshoot just holds at the end for a moment.
Springs and bounces with linear()
linear() is an easing made of points joined by straight lines. With two points it is the linear keyword; with enough of them it can follow any curve, including ones that swing past 1 several times. Each point is a progress value, optionally with the time it applies at:
/* Points spread evenly: 0 at the start, 0.25 half way, 1 at the end. */
.a { transition-timing-function: linear(0, 0.25, 1); }
/* Or pin a point to a time: 0.8 of the way at 30% of the time. */
.b { transition-timing-function: linear(0, 0.8 30%, 1); }Nobody writes a spring by hand. Write the curve as a function of t and sample it. This one is the spring svg-scroll-draw ships, which peaks at 1.358 and settles; 41 evenly spaced points follow it to within 0.004:
// Any easing function as CSS linear(): n + 1 points, evenly spaced in time.
function toLinear(ease, n = 40) {
const points = [];
for (let i = 0; i <= n; i++) points.push(+ease(i / n).toFixed(4));
return `linear(${points.join(', ')})`;
}
// A spring: svg-scroll-draw's createSpring() at its defaults, written out.
const spring = (t) => 1 - Math.cos(t * Math.PI * 2.5) * Math.pow(1 - t, 2.2);
document.documentElement.style.setProperty('--spring', toLinear(spring));Then use var(--spring) as the easing. Even spacing spends points where the curve is nearly straight: to stay within 0.005 of this spring it needs 38. Keeping only the points the shape needs does it with 25, which is short enough to paste:
.pop {
scale: 0;
/* For browsers without linear(): a cubic-bezier that overshoots once. */
transition: scale 900ms cubic-bezier(0.34, 1.56, 0.64, 1);
/* A browser that cannot read linear() drops this line and keeps the one above. */
transition-timing-function: linear(
0, 0.0368 1.415%, 0.0863 2.885%, 0.2268 6.09%, 0.3903 9.145%,
0.8587 17.25%, 0.9847 19.685%, 1.088 21.94%, 1.1824 24.38%, 1.2555 26.77%,
1.3085 29.15%, 1.3422 31.555%, 1.3555 33.47%, 1.3576 35.445%, 1.3487 37.51%,
1.3284 39.71%, 1.2694 43.775%, 1.1115 52.305%, 1.0469 56.26%, 0.9943 60.56%,
0.9628 64.83%, 0.9516 68.24%, 0.9508 72%, 0.9929 88.305%, 1
);
}
.pop.is-in {
scale: 1;
}linear() works in Chrome and Edge 113, Firefox 112, Safari 17.2. A browser older than that drops the whole declaration, which is why the listing gives a cubic-bezier first: it keeps that line and gets one overshoot instead of the spring. The easing visualizer builds springs, bounces and elastic curves with sliders and writes their linear() with how far it can be from the curve.
steps(): motion in jumps
steps(n) does not ease at all: it holds the value still and jumps, n times. That is what a sprite sheet needs, where a background slides one whole frame at a time, and what a typewriter or a ticking clock needs.
/* A sprite sheet: 8 frames of 100px side by side, 800px in all. */
.walker {
width: 100px;
height: 100px;
background: url(walk.png) 0 0 / 800px 100px;
animation: walk 800ms steps(8) infinite;
}
@keyframes walk {
to { background-position: -800px 0; }
}The second argument says where the jumps go. The default, jump-end, holds the first value and reaches the last only at the very end; jump-start jumps at once and holds the last value; jump-none holds both the first and the last for a step each; jump-both holds neither. For steps(4, …):
| Time | jump-start | jump-end | jump-none | jump-both |
|---|---|---|---|---|
| 0.1 | 0.25 | 0 | 0 | 0.2 |
| 0.3 | 0.5 | 0.25 | 0.333 | 0.4 |
| 0.6 | 0.75 | 0.5 | 0.667 | 0.6 |
| 0.9 | 1 | 0.75 | 1 | 0.8 |
start and end are older names for jump-start and jump-end, and step-start and step-end are steps(1, jump-start) and steps(1, jump-end). In the sprite above, steps(8) with its default jump-end holds 0, −100px and so on to −700px, one frame each for an eighth of the time. −800px would show frame one again, and it is never held: the next loop starts at 0, which is frame one.
Easing on a scroll-driven animation
With animation-timeline, the scroll position takes the place of time, and the easing maps scroll progress to animation progress the same way. One thing catches people out: the default is still ease. A reading progress bar without linear is 80% full when the page is half read.
@keyframes grow {
from { scale: 0 1; }
to { scale: 1 1; }
}
.progress {
position: fixed;
inset: 0 0 auto;
height: 4px;
transform-origin: left;
/* linear: the bar is exactly as full as the page is scrolled. */
animation: grow linear both;
animation-timeline: scroll(root);
}Linear is the usual choice for scrubbing because the reader is already supplying the motion: they speed up, slow down and stop, and the page should follow their hand exactly. An easing on top makes the same flick of the wheel move things by different amounts at different points, which reads as lag or a jump, not as softness.
Easing still helps where the animation has a clear finish inside a short range, like a card arriving. There, ease-out puts most of the movement at the start of the range, so the card is nearly in place early and spends the rest of the scroll settling, rather than still sliding as the reader looks at it:
@keyframes rise {
from { opacity: 0; translate: 0 48px; }
}
.card {
/* ease-out: most of the rise happens early in the range, the rest settles. */
animation: rise ease-out both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}Scroll timelines work in Chrome and Edge 115, Safari 26 and Firefox 159. How view(), scroll() and the ranges work is in CSS scroll-driven animations explained.
The same curve as a JavaScript function
Code that animates in requestAnimationFrame needs the easing as a function from time to progress. A cubic-bezier is not written that way: both x and y are cubics in a third number, s, the position along the curve. So the function has to find the s whose x is the time t, then return the y there. Newton’s method finds it in a few steps; where the curve is flat in x it can stall, so bisection takes over, which always finishes because x only ever rises.
// cubic-bezier(x1, y1, x2, y2) as a function of time t, from 0 to 1.
function cubicBezier(x1, y1, x2, y2) {
// Each coordinate is a cubic in s, the position along the curve.
const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;
const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
const X = (s) => ((ax * s + bx) * s + cx) * s;
const Y = (s) => ((ay * s + by) * s + cy) * s;
const dX = (s) => (3 * ax * s + 2 * bx) * s + cx;
return (t) => {
if (t <= 0) return 0;
if (t >= 1) return 1;
// Newton's method: a few steps, wherever the curve is not flat in x.
let s = t;
for (let i = 0; i < 8; i++) {
const e = X(s) - t;
if (Math.abs(e) < 1e-7) return Y(s);
const d = dX(s);
if (Math.abs(d) < 1e-6) break;
s -= e / d;
if (s < 0 || s > 1) break;
}
// Bisection: slower, but always lands, because X never falls.
let lo = 0, hi = 1;
s = t;
while (hi - lo > 1e-7) {
if (X(s) < t) lo = s; else hi = s;
s = (lo + hi) / 2;
}
return Y(s);
};
}It matches the browser’s own cubic-bezier() to well within a thousandth in Chromium, Firefox and WebKit, for every curve in this post. Use it with a time you measure yourself:
const easeOutBack = cubicBezier(0.34, 1.56, 0.64, 1);
function animate(el, from, to, ms, ease) {
const start = performance.now();
requestAnimationFrame(function frame(now) {
const t = Math.min((now - start) / ms, 1);
el.style.translate = `${from + (to - from) * ease(t)}px 0`;
if (t < 1) requestAnimationFrame(frame);
});
}
animate(document.querySelector('.ball'), 0, 300, 600, easeOutBack);If all you need is one element moving, you may not need the function at all: element.animate() takes the same cubic-bezier(), linear() and steps() strings as CSS, and a browser can run it off the main thread, as it does a CSS animation of a transform or opacity.
// The Web Animations API takes the CSS strings as they are.
document.querySelector('.ball').animate(
{ translate: ['0 0', '300px 0'] },
{ duration: 600, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)', fill: 'forwards' },
);Easing in svg-scroll-draw
svg-scroll-draw’s easing option takes one of 7 names, a CSS cubic-bezier() string, or any function of t. The time is how far the scroll is through the trigger window, so the easing decides how much of the motion each stretch of scroll gets. scrollDraw, scrollParallax, scrollProgress, scrollVideo and scrollHorizontal default to linear; scrollAnimate, scrollCounter, scrollReveal and scrollText default to ease-out.
import { scrollDraw, scrollAnimate, createSpring } from 'svg-scroll-draw';
// A name: linear, ease-in, ease-out, ease-in-out, spring, bounce, elastic.
scrollDraw('#route', { easing: 'ease-in-out' });
// A factory, with your own numbers.
scrollAnimate('#badge', {
props: { transform: ['scale(0.6)', 'scale(1)'] },
easing: createSpring({ tension: 3, friction: 1.8 }),
});
// A CSS curve, written as you would in a stylesheet (2.26.0+).
scrollDraw('#logo', { easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' });
// Any function of t, like cubicBezier() above: the same curve.
scrollDraw('#logo', { easing: cubicBezier(0.34, 1.56, 0.64, 1) });| Name | Curve | In CSS |
|---|---|---|
| linear | t | linear |
| ease-in | t² | cubic-bezier(0.333333, 0, 0.666667, 0.333333) |
| ease-out | t(2 − t) | cubic-bezier(0.333333, 0.666667, 0.666667, 1) |
| ease-in-out | 2t² to ½, then −1 + (4 − 2t)t | linear() |
| spring | 1 − cos(2.5πt)(1 − t)^2.2 | linear() |
| bounce | rise, then 2 dips | linear() |
| elastic | 2^(−10t) sin(…) + 1 | linear() |
cubic-bezier() string, scrollDraw and scrollAnimate can hand the animation to the browser’s own scroll timeline.The names are the library’s own curves, not the CSS keywords. Its ease-out is t(2 − t), up to 0.069 away from CSS’s ease-out, and is exactly the cubic-bezier in the table, so when the library hands an animation to the browser it writes that and not the keyword. For a CSS curve, write the cubic-bezier() itself, as in a stylesheet: since 2.26.0 the library solves it the way cubicBezier() above does, and hands the browser’s scroll timeline the same four numbers. The keyword ease works too.
— / next
Drag the handles, copy the curve
Move P1 and P2, race the curve against ease or easeOutBack, and copy the cubic-bezier() for CSS or the JavaScript function for your own code.
Star on GitHubFound this useful? A star helps others find it.