9 CSS Easing Curves You Can Copy-Paste
A CSS animation moving at one constant speed looks robotic; ease it down at the end, or spring past the target and settle, and it reads as natural.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 ease-out-quart curve
- 02 ease-in-out-back curve
- 03 ease-out-expo curve
- 04 Bounce via linear()
- 05 Gentle spring
- 06 Snappy spring
- 07 SCSS easing map mixin
- 08 Tailwind v4 --ease token
- 09 Side-by-side easing compare
These nine aren't ranked by popularity — they're ordered by how much control each one hands over. Three curves an arriving element might use come first (01–03), then three springs that only linear() can draw, the kind cubic-bezier() can't touch (04–06), then two ways to reuse those exact values instead of retyping them everywhere (07–08), and last, a demo that runs four of them side by side so the differences read at a glance (09).
01ease-out-quart curve
Put cubic-bezier(.25, 1, .5, 1) on a translateX and the element shoots out fast from the very first frame, then settles near the target with no overshoot at all. It reads well on a modal or drawer opening, or a card arriving on screen.
$duration: $dur-loop; // 2s
$easing: cubic-bezier(.25, 1, .5, 1); // ease-out-quart
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
02ease-in-out-back curve
cubic-bezier(.68, -.6, .32, 1.6) pushes its y-values past 0 and 1, so the element pulls back a little before it leaves, then overshoots the target and settles back after arriving. It suits a toast or badge that wants to pop in with some personality.
$easing: cubic-bezier(.68, -.6, .32, 1.6); // ease-in-out-back
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
03ease-out-expo curve
cubic-bezier(.16, 1, .3, 1) covers most of the distance in the first few frames, then crawls the rest of the way so slowly it barely reads as moving at all. Use it on a sidebar or dropdown that should feel like it snaps open fast.
$easing: cubic-bezier(.16, 1, .3, 1); // ease-out-expo
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
04Bounce via linear()
cubic-bezier() can only bend one way, so it can never make a value rise, fall, and rise again — the way a dropped ball actually behaves. Sampling Robert Penner's easeOutBounce formula into 25 points for linear() produces that real bounce, a shape cubic-bezier() alone cannot draw.
$easing: linear(0, .013, .053, .118, .21, .328, .473, .643, .84, .97, .875, .807, .766, .75, .761, .798, .861, .951, .973, .943, .939, .962, .995, .984, 1);
.stage.is-demo .puck {
animation: drop $duration $easing infinite;
}
@keyframes drop {
from { transform: translate(-50%, 0); }
to { transform: translate(-50%, $drop); }
}
05Gentle spring
This linear() value is sampled 23 points deep from a damped-spring equation — damping ratio .62, angular frequency 7.0. It overshoots the target by about 8% and settles quickly, no drama, which is a good match for a settings panel sliding into place.
$easing: linear(0, .044, .153, .297, .452, .602, .736, .849, .937, 1.002, 1.045, 1.071, 1.082, 1.083, 1.077, 1.066, 1.053, 1.04, 1.028, 1.018, 1.009, 1.003, 1);
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
06Snappy spring
Lower that same equation's damping ratio to .35 and raise its angular frequency to 9.0, and the curve overshoots its target by more than 30% before oscillating a couple more times — visibly springy. It fits a like-button press or a card snapping back after a drag release.
$easing: linear(0, .055, .197, .394, .612, .824, 1.009, 1.153, 1.25, 1.3, 1.307, 1.281, 1.231, 1.167, 1.099, 1.035, .982, .942, .916, .905, .907, .917, .935, .955, .976, .995, 1);
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
07SCSS easing map mixin
A single $easings Sass map holds every curve from this post by name, and @mixin ease($name) pulls the right one through map.get() so no other component ever has to paste a raw curve value again. It's the pattern a design system reaches for once several parts need to share one brand easing.
$easings: (
"ease-out-quart": cubic-bezier(.25, 1, .5, 1),
"ease-in-out-back": cubic-bezier(.68, -.6, .32, 1.6),
"spring-gentle": linear(0, .044, .153, .297, .452, .602, .736, .849, .937, 1.002, 1.045, 1.071, 1.082, 1.083, 1.077, 1.066, 1.053, 1.04, 1.028, 1.018, 1.009, 1.003, 1),
"spring-snappy": linear(0, .055, .197, .394, .612, .824, 1.009, 1.153, 1.25, 1.3, 1.307, 1.281, 1.231, 1.167, 1.099, 1.035, .982, .942, .916, .905, .907, .917, .935, .955, .976, .995, 1),
);
@mixin ease($name, $prop: animation-timing-function) {
#{$prop}: map.get($easings, $name);
}
.stage.is-demo .puck {
animation-name: move;
animation-duration: $duration;
animation-iteration-count: infinite;
animation-direction: alternate;
@include ease("spring-snappy"); // pulls item 06's exact value out of the map
}
08Tailwind v4 --ease token
Declaring --ease-spring as a plain :root custom property and dropping var(--ease-spring) into the easing slot of the animation shorthand reproduces Tailwind v4's @theme --ease-* token pattern without ever loading Tailwind itself. Reach for it when a project wants its easing named and reused like a utility class.
$easing: var(--ease-spring);
:root {
--ease-spring: cubic-bezier(.34, 1.56, .64, 1);
}
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
09Side-by-side easing compare
Quart, back, snappy spring, and bounce run over the same two seconds and the same distance, side by side, so the shape of each deceleration is obvious at a glance. None of the four values are new — they're items 01, 02, 06, and 04, reused exactly as they are.
.puck--quart { animation: move $duration cubic-bezier(.25, 1, .5, 1) infinite alternate; }
.puck--back { animation: move $duration cubic-bezier(.68, -.6, .32, 1.6) infinite alternate; }
.puck--spring { animation: move $duration linear(0, .055, .197, .394, .612, .824, 1.009, 1.153, 1.25, 1.3, 1.307, 1.281, 1.231, 1.167, 1.099, 1.035, .982, .942, .916, .905, .907, .917, .935, .955, .976, .995, 1) infinite alternate; }
.puck--bounce {
animation: move-once $duration linear(0, .013, .053, .118, .21, .328, .473, .643, .84, .97, .875, .807, .766, .75, .761, .798, .861, .951, .973, .943, .939, .962, .995, .984, 1) infinite;
}
Where does the vertical bounce track break?
Item 04's ball first fell 180px, not 132px. On the 480×300 desktop stage that looked completely fine — nothing overflowed, nothing clipped, and that first pass shipped without anyone noticing a problem. The trouble only showed up once this site's mobile check kicked in: shrinking the stage to a 320px width also shrinks its height by the same ratio, 320×300÷480, down to 200px, and a 180px vertical track plus its top and bottom dots blew past that 200px budget with room to spare. Rendering the small version and measuring the actual frames is what caught it — the automated overflow flag in this post's measurement file read true for item 04 alone, while every other item here, all of them sideways instead of vertical, read false. A sideways track has slack inside a 320px width that a vertical one never gets, because a vertical track runs straight into the narrower 200px height budget the moment it's tall enough to matter. Cutting the track to 132px, set once as a Sass variable rather than a number copied into several places, removed the overflow for good and flipped that flag back to false. Item 04 is also the only demo in this set that moves along a vertical axis at all — the other eight all run translateX sideways, which is exactly why the narrower mobile height budget never got exercised until this one bounced into it. Nothing else about the curve changed: the same 25-point linear() value drives the same 2-second loop, only the track it travels got shorter, and the 8px dot markers at each end stayed exactly the size they were before. Opening the zip asks for a password; it's wnxge56e, typed exactly as it reads here, and the files unlock. A vertical animation checked only against the usual horizontal breakpoint is an easy trap — the mobile viewport's height needs its own pass, the same way this one finally got one.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Slower quart | Item 01's cubic-bezier(.25, 1, .5, 1) swapped for item 03's cubic-bezier(.16, 1, .3, 1) |
The arrival stretches out instead of settling right away |
| Heavier back | Item 02's -.6 / 1.6 control points pushed further, to -.9 / 1.9 |
Both the pull-back and the overshoot read as far more exaggerated |
| Calmer spring | Item 06's damping ratio raised from .35 toward item 05's .62 |
Overshoot drops from 30%+ to roughly 8%, and the extra oscillation nearly disappears |
Accessibility
All nine drop to animation: none under prefers-reduced-motion: reduce, and each one freezes on its arrival point in transform rather than some mid-motion frame — 196px or 188px along the track for 01, 02, 03, 05, 06, 07, and 08, 108px down for 04's drop, and each of the four lanes at its own endpoint for 09. This post exists to show the shape of a curve, so turning off motion removes exactly the thing being demonstrated, but the layout itself never shifts, since the final position is already written into transform either way. Browser support is the other limit worth knowing before copying any of this: cubic-bezier() has worked everywhere since CSS transitions shipped, but linear() only reached Chrome and Edge at version 113, Firefox at 112, and Safari at 17.2 — an older engine just falls back to whatever timing function sits earlier in the cascade instead of breaking outright. More keyframe basics live in 9 CSS keyframe animations, and the token pattern item 08 reproduces is covered in full in 9 Tailwind v4 animations.
FAQ
What's the real difference between cubic-bezier() and linear()?
cubic-bezier() draws one smooth curve from four numbers, and that curve can only bend once — it can never make a value rise, fall, and rise again. linear() takes as many points as it's given, so a curve sampled straight from a real formula, like items 04 through 06 here, carries over exactly instead of getting approximated.
Does CSS have a built-in spring easing keyword?
No, not yet — a dedicated spring() easing function hasn't landed in browsers. Until it does, the practical way to get spring motion in CSS is to solve a damped-spring equation directly, the way items 05 and 06 do here, and sample the result into linear().
Is there a way to get these values without doing the math myself?
For cubic-bezier(), a named-preset list like easings.net covers most everyday curves, and both Chrome's and Firefox's DevTools animation panels let you drag a curve and read off the numbers live. linear() values sampled from a physical formula, like the springs and bounce in this post, are more accurately produced by solving the equation in Python or JavaScript than by eyeballing a curve editor.