08 / blog / guideNovember 20269 min read

CSS cubic-bezier() easing explained, with curves to copy

cubic-bezier() is four numbers that bend a straight line from start to end into a curve, and the curve decides how fast a transition moves at each moment. Here is what each number does, the exact values behind ease and ease-in-out, a table of curves to copy, springs and bounces with linear(), and the same curve as a JavaScript function.

  • Guide
  • cubic-bezier()
  • linear()
  • Easing
CSS keywords
5
Curves to copy
24
Spring in linear()
25 points
  • ease

    0.25, 0.1, 0.25, 1

  • ease-in

    0.42, 0, 1, 1

  • ease-out

    0, 0, 0.58, 1

  • ease-in-out

    0.42, 0, 0.58, 1

  • easeOutBack

    0.34, 1.56, 0.64, 1

  • easeInOutBack

    0.68, -0.6, 0.32, 1.6

Fig. 1 — six easings drawn from their own four numbers. Each curve is an SVG cubic path with P1 and P2 as its control points, the same curve the browser eases with. Across is time, up is progress, the box is 0 to 1 and the dashed line is linear. A handle cut at the edge goes on to the y in its numbers.
01The model

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.txt7 lines
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 number

P1 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.css7 lines
.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.

invalid.css5 lines
.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);
}
02The keywords

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.

KeywordIs exactlyProgress at half time
easecubic-bezier(0.25, 0.1, 0.25, 1)0.802
ease-incubic-bezier(0.42, 0, 1, 1)0.315
ease-outcubic-bezier(0, 0, 0.58, 1)0.685
ease-in-outcubic-bezier(0.42, 0, 0.58, 1)0.5
linearcubic-bezier(0, 0, 1, 1)0.5
Progress at half time is computed from the numbers, and matches the browser’s own keyword in Chromium, Firefox and WebKit.

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.

03Reading

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.

KeywordSpeed at the startSpeed at the end
ease0.4 × linear0 × linear
ease-in0 × linear1.72 × linear
ease-out1.72 × linear0 × linear
ease-in-out0 × linear0 × linear
linear1 × linear1 × linear
The slope of each curve at its two ends, read from the curve itself.

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).

0424 curves

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%.

FamilyInOutIn-out
Sine0.12, 0, 0.39, 00.61, 1, 0.88, 10.37, 0, 0.63, 1
Quad0.11, 0, 0.5, 00.5, 1, 0.89, 10.45, 0, 0.55, 1
Cubic0.32, 0, 0.67, 00.33, 1, 0.68, 10.65, 0, 0.35, 1
Quart0.5, 0, 0.75, 00.25, 1, 0.5, 10.76, 0, 0.24, 1
Quint0.64, 0, 0.78, 00.22, 1, 0.36, 10.83, 0, 0.17, 1
Expo0.7, 0, 0.84, 00.16, 1, 0.3, 10.87, 0, 0.13, 1
Circ0.55, 0, 1, 0.450, 0.55, 0.45, 10.85, 0, 0.15, 1
Back0.36, 0, 0.66, -0.560.34, 1.56, 0.64, 10.68, -0.6, 0.32, 1.6
Values from the cubic-bezier generator, which also says how far each is from the equation it is named after.

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.

05Past the ends

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:

Namecubic-bezierLowestHighest
easeInBack0.36, 0, 0.66, -0.56-0.098 at 0.431 at 1
easeOutBack0.34, 1.56, 0.64, 10 at 01.098 at 0.57
easeInOutBack0.68, -0.6, 0.32, 1.6-0.105 at 0.211.105 at 0.79
Lowest and highest progress and the time each is reached, sampled from the curve. The handles go further out than the curve does.
toast.css8 lines
.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.

06linear()

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:

linear.css5 lines
/* 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:

to-linear.js11 lines
// 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.css16 lines
.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.

07steps()

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.

walker.css10 lines
/* 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, …):

Timejump-startjump-endjump-nonejump-both
0.10.25000.2
0.30.50.250.3330.4
0.60.750.50.6670.6
0.910.7510.8
Progress from each jump position, from the spec’s step algorithm, checked against the browser’s own steps() in three engines.

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.

08scroll-driven

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.

progress.css14 lines
@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:

settle.css10 lines
@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.

09Javascript

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.js32 lines
// 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:

animate.js12 lines
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.

waapi.js5 lines
// 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' },
);
10svg-scroll-draw

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.

main.js16 lines
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) });
NameCurveIn CSS
lineartlinear
ease-int²cubic-bezier(0.333333, 0, 0.666667, 0.333333)
ease-outt(2 − t)cubic-bezier(0.333333, 0.666667, 0.666667, 1)
ease-in-out2t² to ½, then −1 + (4 − 2t)tlinear()
spring1 − cos(2.5πt)(1 − t)^2.2linear()
bouncerise, then 2 dipslinear()
elastic2^(−10t) sin(…) + 1linear()
The names and their maths, from svg-scroll-draw’s core/easing.ts. With 'linear', 'ease-in', 'ease-out' and 'ease-in-out' or a 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.

→Parts list

Keep reading

More from the blog