Reading the cubic-bezier() Easing Curve: How Four Numbers Shape a CSS Transition
A practical guide to cubic-bezier() easing in CSS: what the four control points mean, how the curve shapes acceleration, and when a custom curve beats ease-in/out.
Reading the cubic-bezier() Easing Curve: How Four Numbers Shape a CSS Transition
Every CSS transition has a speed, and that speed is almost never constant. A panel that slides in at a fixed rate looks robotic; the same panel that starts fast and settles slow feels designed. The thing controlling that difference is the timing function, and when the five built-in keywords are not enough, you reach for cubic-bezier(). It looks cryptic the first time you see four decimals in a row, but each number does one specific job. Once you can read them, you can shape motion deliberately instead of pasting values you found in a Stack Overflow answer.
This post walks through what those four numbers are, how the curve translates into acceleration, and when a custom curve earns its place over the stock keywords.
The curve always runs from (0,0) to (1,1)
A cubic-bezier() timing function is a curve drawn inside a unit box. The horizontal axis is the progress of time: 0 is the moment the transition starts, 1 is the moment it ends. The vertical axis is the eased output: 0 is the start value of whatever you are animating, 1 is the end value. The curve always begins at the bottom-left corner (0,0) and finishes at the top-right corner (1,1). Those two endpoints are fixed and you never set them.
What you do set is the shape of the curve between those corners, and you do it with two control points. That is the whole idea behind cubic-bezier(x1, y1, x2, y2): the four numbers are the coordinates of two control handles. (x1, y1) pulls the early part of the curve toward it, and (x2, y2) pulls the late part. The steeper the curve gets at any point, the faster that part of the animation runs; where the curve flattens, the motion slows down. So a curve that shoots up steeply near the start and flattens toward the end is something that begins quickly and decelerates into place.
The x values are bounded to 0 and 1 because time only moves forward. If you put x1 at 1.5, the curve would loop back on itself and the easing would be undefined. The y values have no such limit. A y above 1 means the value overshoots its target before settling, and a y below 0 means it pulls backward first. That is exactly how bounce and anticipation are built.
How the slope becomes acceleration
It helps to stop thinking of the curve as a picture and start reading it as a speedometer. Walk along the x axis from 0 to 1, which is time elapsing. At each moment, look at how steeply the curve is climbing. A near-vertical section means a large change in output for a small slice of time, so the element is moving fast right there. A near-horizontal section means almost no change, so the element is nearly still.
This is why ease-in and ease-out feel opposite. The keyword ease-in equals cubic-bezier(0.42, 0, 1, 1): the curve starts shallow and ends steep, so the animation creeps at first and races at the end. ease-out equals cubic-bezier(0, 0, 0.58, 1): steep at the start, shallow at the end, so it bursts out of the gate and glides to a stop. The default ease keyword, cubic-bezier(0.25, 0.1, 0.25, 1), is a gentle version of ease-out that suits most interface motion, which is why it is the default.
One trap worth naming: the handle position is not the value at that time. The curve maps an x (progress) to a y (output), and the relationship is not linear with the underlying parameter. If you want to know the eased output at the halfway point of a transition, you have to read y at x = 0.5, not guess from where a handle sits. A visual generator with a solver does that arithmetic for you, which matters when you are debugging a transition that feels like it jumps.
A worked example: shaping an ease-out
Say you have a card that fades and lifts on hover, and the stock ease-out feels slightly too eager. You want it to leave fast but spend more time gliding into place. Start from ease-out, which is cubic-bezier(0, 0, 0.58, 1), and pull the second control point's x down toward the middle:
.card {
transition: transform 0.3s cubic-bezier(0, 0, 0.4, 1),
opacity 0.3s cubic-bezier(0, 0, 0.4, 1);
}
.card:hover {
transform: translateY(-6px);
}
Reading cubic-bezier(0, 0, 0.4, 1): the first handle sits at the origin, so the curve launches with a steep slope, meaning the card moves a lot in the first fraction of the 0.3s. The second handle at (0.4, 1) pulls the late curve up early and then flattens it, stretching out the slow glide near the end. Compared with plain ease-out, more of the 300ms is spent in the slow tail, so the lift reads as smoother and more deliberate. If you read y at progress 0.5 with a solver, you will see the card has already covered roughly 80% of its distance, which is the signature of a strong ease-out: most of the motion happens early.
If instead you wanted a little bounce on a notification badge, you would push a y past 1, for example cubic-bezier(0.68, -0.55, 0.27, 1.55). The early y of -0.55 dips the badge backward, then the late y of 1.55 sends it past its target before it settles. Same four-number grammar, completely different feel.
When a custom curve actually earns its keep
Honestly, the five keywords cover most of what an interface needs. I reach for a custom curve in three situations: when a brand or design system specifies a motion feel the keywords cannot hit, when I need overshoot or anticipation that no keyword can express, and when I am matching one feel across many properties so a card lift, a fade, and a shadow change all move together. For a plain hover state, ease-out is usually fine and a hand-tuned curve is over-engineering.
The fastest way to land on a curve I trust is to drag the handles, watch a dot run the motion at my real duration, and copy the line. That is exactly what the CSS Cubic Bezier Generator is for: it loads any of the five presets so you start from a known curve, lets you nudge each value with a slider, and reverses x into y so you can read the eased output at any progress. The shareable URL reproduces the exact curve, which makes it easy to drop a reference into a design doc without a screenshot.
Fitting the curve into your stylesheet
The output of a timing function is one declaration. You can attach it inline as part of the transition shorthand, as in transition: transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1), or set transition-timing-function on its own line. The same value works in animation-timing-function for @keyframes, so a keyframed pop and a transitioned slide can share one feel.
One last reminder from experience: a timing function only shapes how motion runs over a duration. If you copy a beautiful curve but forget to set a transition or animation duration, there is nothing for the curve to bend and you will see no effect at all. Always pair the curve with a duration.
When the easing is dialed in and you are wiring it across a stylesheet, it is easy to leave the declarations in an inconsistent mess of spacing and ordering. Running the file through the CSS Formatter before you commit keeps the timing-function lines readable, which matters when the next person on the team has to tweak the same curve.
Four numbers, two handles, one curve from corner to corner. Once you read the slope as speed, cubic-bezier() stops being a magic incantation and becomes a tool you actually control.
Made by Toolora · Updated 2026-06-13