CSS cubic-bezier(): How Custom Easing Curves Actually Work
What the four cubic-bezier() numbers mean, how to build overshoot and anticipation effects, and when the built-in ease keywords are enough.
ease-in-out covers more cases than people give it credit for. But every so often a designer hands you a Lottie animation with a bounce in it, or a product manager says a modal should “pop” open, and the five built-in keywords stop being enough. That’s what cubic-bezier() is for, and the math behind it is less mysterious than the name suggests.
The Cubic Bezier Easing Generator lets you drag two points on a graph and watch a ball move with the resulting curve. This post covers what those two points are actually doing.
Two points, four numbers
cubic-bezier(x1, y1, x2, y2) describes a curve between two fixed endpoints: it always starts at (0, 0) and ends at (1, 1). You never see those two points in the function because they can’t move; an animation always begins at 0% progress and ends at 100%. The four numbers you do provide are the coordinates of two control points that pull the curve between those endpoints.
Read the axes as time and progress. X is elapsed time through the animation, scaled from 0 to 1. Y is how far along the animated property is, also scaled 0 to 1 — except y isn’t actually capped at that range, which is where the interesting effects come from.
Why x is capped and y isn’t
x can’t go below 0 or above 1 because time in a single animation run can’t go backward or past its own end. The browser clips any x value you give it into that range.
y has no such restriction. Nothing stops the curve from putting the animated property at 120% progress partway through, then settling back to 100%. That’s not a bug in the math — it’s a deliberate feature, and it’s exactly how overshoot and bounce effects are built.
Building an overshoot
Drag the second control point (the one nearer the end of the curve) up past y = 1. Something like cubic-bezier(0.34, 1.56, 0.64, 1) sends the animated element past its target before it settles, which reads as a soft, springy bounce. I use this a lot on toast notifications and success checkmarks. A plain ease-out finish feels flat by comparison, and the overshoot version costs nothing extra in bundle size since it’s still one CSS value.
The mirror trick works at the start: drag the first control point below y = 0, and the element dips backward slightly before moving forward — an anticipation pull, the same principle animators have used in traditional animation for decades, just expressed as two numbers instead of a stack of hand-drawn frames.
The five keywords, translated
Every named easing keyword is shorthand for a specific cubic-bezier() call:
lineariscubic-bezier(0, 0, 1, 1)— constant speed, no acceleration at alleaseiscubic-bezier(0.25, 0.1, 0.25, 1)— the CSS default, quick start into a gentle stopease-iniscubic-bezier(0.42, 0, 1, 1)— slow start, no easing at the endease-outiscubic-bezier(0, 0, 0.58, 1)— no easing at the start, slow finishease-in-outiscubic-bezier(0.42, 0, 0.58, 1)— symmetrical acceleration and deceleration
ease is what you get if you write a transition with no timing function at all, and honestly it’s the right choice more often than people assume. Reach for a custom curve when the default genuinely doesn’t fit, not as a default habit.
Where the curve actually applies
cubic-bezier() works anywhere CSS expects a timing function: transition-timing-function, animation-timing-function, and per-keyframe timing inside @keyframes. A modal that needs a punchier open than its close, for instance, can use one curve on transition-timing-function for the open state and a calmer one for the close — no JavaScript required, just two different values.
Play with the graph in the Cubic Bezier Easing Generator, hit Play to see the motion for real, and copy the value once it feels right.