Skip to main content

Editing cubic-bezier Easing Curves by Hand: A Practical Guide to CSS Motion

Learn how a cubic-bezier easing curve actually works — the two control points, reading the curve as speed over time, and exporting a clean cubic-bezier() value.

Published By Li Lei
#css #animation #easing #cubic-bezier #frontend

Editing cubic-bezier Easing Curves by Hand

For a long time I treated CSS easing as a dropdown. ease, ease-in, ease-out, done. Then a designer handed me a Framer prototype, I ported it to CSS, and my version felt subtly wrong in a way I could not name. The fix was not a new keyword. It was learning to read the curve itself: what the two control points do, why the slope is the thing that matters, and how to get a cubic-bezier() value I trusted out the other side.

This post is the explanation I wish I had then. If you want to follow along live, open the Cubic Bezier Editor in another tab and drag the points as you read.

What a cubic-bezier curve actually is

A CSS cubic-bezier easing curve is defined by exactly two control points: (x1, y1, x2, y2). The start and end are fixed at (0, 0) and (1, 1) — you never move those. So the four numbers in cubic-bezier(0.42, 0, 0.58, 1) are the two handles you can move, and nothing else.

The axes are the whole trick. X is time, normalized so 0 is the first frame and 1 is the last. Y is value, normalized so 0 is the start state and 1 is the end state. That "value" can be a position, a scale, an opacity, a rotation — anything you are animating.

Here is the part that makes the curve readable instead of magic: the slope of the curve at any point is the speed of the animation at that moment. A steep section means the value is changing fast (the element is moving quickly). A flat section means the value is barely changing (the element looks like it has paused). A straight diagonal line — which is exactly what linear is — means constant speed from start to finish, which is why linear motion reads as robotic. Real objects accelerate and decelerate; their speed-over-time graph is a curve, not a ruler.

Once you see slope as speed, the control points stop being abstract. Pull the curve flat near the start and you get a slow build (ease-in). Pull it steep near the start and flat near the end and you get a fast launch that settles (ease-out). The handles are just you sculpting where the motion spends its time.

The two control points, one at a time

The first control point governs how the motion leaves the start. The second governs how it arrives at the end. They are independent, which is why you can have a curve that launches gently and slams shut, or one that snaps out and eases in.

There is one hard rule baked into the spec, not the tool: the X coordinate of both control points must stay in [0, 1]. X is time, and time cannot run backwards, so a control point cannot sit to the left of the start or the right of the end. The editor clamps X for you so whatever you copy is always valid CSS.

Y has no such limit. Y is the value axis, and the spec deliberately lets it escape [0, 1]. Push y2 above 1 and the element overshoots its target — it sails past, then comes back. Pull y1 below 0 and the element anticipates — it pulls backward to wind up before moving forward. That single freedom is what gives you bounce, overshoot, and spring-like curves out of a plain four-number bezier.

Reading the curve as speed over time

When I am tuning a curve I stop looking at the shape as a shape and start narrating it left to right: "slow start, builds to fast in the middle, eases out at the end." If I can say that sentence and it matches what the animation should feel like, the curve is right.

This is also why the editor runs three previews at once — a moving ball, a scaling box, and a fading square — on a single animation loop. Position is the classic test, but scale velocity and opacity behave differently. A curve that feels great moving a ball can look wrong on opacity, because opacity is capped at 1: if your y2 overshoots above 1, the fade shoots to fully opaque, pauses there while the curve is still above 1, then comes back. That pause reads as a flicker. Watching the same curve drive three properties at once is the fastest way to catch it before it ships.

A worked example: a real ease-out

Take cubic-bezier(0, 0, 0.2, 1). Read it as speed over time. The first control point sits at the origin (0, 0), so the motion starts at full commitment — no slow wind-up. The second point at (0.2, 1) pulls the curve up to its end value very early in time, which means the curve is steep at the start and almost flat by the end.

In plain language: the element launches fast and decelerates into place. Drop it on a panel that slides in:

.panel {
  transition: transform 320ms cubic-bezier(0, 0, 0.2, 1);
}

At 320ms this feels crisp and confident — the panel arrives quickly, then settles without a hard stop. This is the everyday workhorse for entrances, because human attention tolerates a fast start far better than a fast stop. Compare it to the symmetric ease-in-out (0.42, 0, 0.58, 1), which spends time ramping up and down; that one is better for state changes where nothing is entering or leaving.

One thing the example hides: duration. The same cubic-bezier(0, 0, 0.2, 1) at 150ms is barely perceptible as a curve at all — under roughly 200ms your eye only catches the start and end states, not the easing between them. The bezier describes the shape of motion; duration controls how long you get to see that shape. Tune the curve at 600–1000ms where you can actually read it, then ship it at whatever your production timing is.

Exporting the cubic-bezier() value

When the curve feels right, you need a value you can paste. The plain CSS form is the one to copy first:

transition: transform 320ms cubic-bezier(0, 0, 0.2, 1);
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);

It is a self-contained string. Paste it into a teammate's chat and they can paste it back into the four number inputs and see the exact same curve — no screenshots, no "trust me, it looks good." The same four numbers also drop straight into a Framer Motion ease array (same x1, y1, x2, y2 order), into a GSAP CustomEase as an SVG path, or into Figma as a path for documentation.

A standing trap worth naming: the array order is not the same everywhere. CSS and Framer Motion both use (x1, y1, x2, y2). Type it as (y1, x1, y2, x2) by accident and the curve looks "almost right but not quite" — which is exactly the bug that cost me an afternoon before I learned to read curves.

Where this fits in your CSS toolkit

An easing curve is only half of a good transition; the other half is what you animate. I keep the editor next to the CSS Transform Generator so I can dial in the translate, scale, and rotate I want to move, then choose the curve that moves it the way the design intends. Once you can read a curve as speed over time, picking the right one stops being guesswork — you describe the motion you want in a sentence, then sculpt the two control points until the curve says the same thing.


Made by Toolora · Updated 2026-06-13