CSS Custom Easing: linear(), steps() and Beyond cubic-bezier
cubic-bezier had a good run. Now linear() and steps() give you spring physics, staircase effects, and total control over your CSS animations.
Why cubic-bezier Isn't Enough Anymore
For years, cubic-bezier() was the answer to every 'make this feel better' animation request. Four control points, endlessly tweakable, shipped in every browser since 2010. It covers a huge range — bouncy, snappy, slow-in, slow-out — and honestly it's still the right tool for most transitions.
But it has a hard ceiling. You can't encode a spring that overshoots and bounces back. You can't fake a rubber band that stretches, snaps, then settles. You can't approximate the easing curves that JavaScript animation libraries have been producing for years because cubic-bezier() is, by definition, a smooth monotonic curve. No wiggles. No reversals. One arc.
That's the gap that linear() and the newly-revisited steps() fill. Shipped broadly in 2023 and now well-supported across all major engines as of 2026, these two functions let you describe easing curves with an arbitrary number of control points — or no interpolation at all. Worth noting: the bounce, spring, and elastic presets you've been reaching for Framer Motion or GSAP to produce? You can now do them in plain CSS.
This article covers what each function actually does, when to use which, and how to produce timing curves that feel hand-crafted rather than algorithmic.
steps() — Frame-by-Frame Control
steps() has been in the spec for a long time, but most developers only know it from the classic typewriter trick: steps(N, end) to jump through character widths with zero interpolation. The full syntax is more interesting than that.
/* Basic N-step jump */
animation-timing-function: steps(8);
/* Where the jump happens: start, end, none, both */
animation-timing-function: steps(8, jump-start); /* jump at beginning of each step */
animation-timing-function: steps(8, jump-end); /* jump at end (default) */
animation-timing-function: steps(8, jump-none); /* first and last values held */
animation-timing-function: steps(8, jump-both); /* extra step at each end */The jump-none keyword is especially underused. It gives you N steps but keeps the first and last keyframe values — meaning your animation holds the start value, then the end value, and never skips either. That matters for sprite sheets (a 24-frame walk cycle at 24fps wants steps(24, jump-none) to land on every frame correctly) and for loading states that need to snap between discrete visual states without the last frame flickering.
Quick aside: steps(1, jump-start) is equivalent to the old step-start, and steps(1, jump-end) matches step-end. The named aliases still work everywhere, but knowing the underlying model lets you reason about complex cases without memorising keywords.
In practice, steps() is the right call any time you want hard-cut motion: pixel art, sprite animation, retro blinking effects, typewriter text, LED-style number counters. It's the opposite of smooth — and that's exactly why it's useful.
linear() — Arbitrary Easing Curves
linear() is the genuinely new thing. It accepts a list of output values (and optional input positions), and the browser linearly interpolates between each consecutive pair. The result is a piecewise-linear curve that can approximate *any* easing shape — including springs, bounces, and elastics that cubic-bezier() physically cannot express.
/* Simple deceleration approximation */
animation-timing-function: linear(0, 0.5 25%, 1);
/* Spring-like overshoot — bounces past 1 then settles */
animation-timing-function: linear(
0, 0.63, 1.08, 1.18, 1.12, 1.0, 0.95, 1.0, 1.0
);
/* You can also specify the input position explicitly */
animation-timing-function: linear(
0 0%,
0.5 20%,
1 100%
);That spring example in the snippet above? Looks hand-rolled and it is — but there are now excellent tools that generate the point list for you from real physics parameters (mass, stiffness, damping). The linear-easing-generator by Jake Archibald is the most popular. You feed it a JavaScript easing function or pick a spring preset, and it spits out a linear() value with however many sample points gives you the fidelity you need.
Look, you're not going to type out 40 control points by hand. The workflow is: design the curve in a generator, paste the value into a CSS custom property, and reference it wherever you need it. That pattern keeps your code clean and lets you iterate on the feel without touching component markup.
:root {
--spring-bounce: linear(
0, 0.009, 0.035 2.1%, 0.141, 0.281 6.7%, 0.723 12.9%,
0.938 16.7%, 1.017, 1.077, 1.106, 1.107 24.5%, 1.077 27.2%,
1.001 35.1%, 0.981, 0.975 41%, 1.001 49.4%, 1
);
}
.card:hover {
transform: translateY(-8px);
transition: transform 600ms var(--spring-bounce);
}Sampling Resolution and Performance
One thing worth being careful about with linear(): the number of sample points you include directly affects how smooth the curve looks at slow speeds or on high-refresh-rate displays. Too few points and the piecewise approximation becomes visible — you see little straight-line segments rather than a smooth arc. For a 120Hz display, you generally want at least 30–40 sample points for any easing that has curve inflections.
That said, browsers optimise linear() well. The values are baked at parse time, not recalculated per frame. Performance is essentially identical to cubic-bezier() for equivalent work. Don't reach for cubic-bezier() thinking it's faster — it isn't.
/* Too few points — visibly segmented at 120fps */
animation-timing-function: linear(0, 0.5, 1.1, 1);
/* Fine-grained — smooth even on 120Hz, generated output */
animation-timing-function: linear(
0, 0.004, 0.016, 0.035, 0.063, 0.098, 0.141 12.5%,
0.25, 0.391, 0.563, 0.765, 1 50%,
1.133, 1.211, 1.25, 1.211, 1.133, 1 75%,
0.938, 0.906, 0.938, 1
);If you're generating animations at 60fps and the easing is short (under 300ms), 12–16 points is usually enough. For longer animations or anything at high refresh rates, 30+ is safer. The generator tools generally let you tune sample count.
Combining with CSS @keyframes and Custom Properties
Here's where it gets genuinely useful for real UI work: you can set animation-timing-function *inside* @keyframes blocks, not just on the element. That means you can apply different easing to different segments of the same animation — a fast ease-in at the start, a spring bounce at the peak, step-end for the final snap.
@keyframes slide-in-bounce {
0% {
transform: translateX(-60px);
animation-timing-function: linear(0, 0.5 30%, 1);
}
70% {
transform: translateX(8px);
animation-timing-function: steps(1, jump-end);
}
85% {
transform: translateX(-4px);
animation-timing-function: linear(0, 1);
}
100% {
transform: translateX(0);
}
}
.panel {
animation: slide-in-bounce 500ms both;
}This is something you've historically needed JavaScript to do — applying different physics to different segments of an animation. Now it's declarative CSS. Combine that with @property to animate custom properties with typed interpolation, and you're building animation systems that feel like they came from a proper animation tool.
Worth noting: glassmorphism components benefit especially from multi-segment easing — the blur and background-color transitions need different feels than the transform, and being able to assign per-keyframe timing makes that possible without JS hacks.
One more thing — @property pairs with linear() well for animated gradients and other properties CSS can't interpolate by default. Define the type, register the property, and the browser handles the rest:
@property --gradient-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
.rotating-gradient {
background: conic-gradient(from var(--gradient-angle), #6c63ff, #ff6584, #43e97b);
animation: spin 3s var(--spring-bounce) infinite;
}
@keyframes spin {
to { --gradient-angle: 360deg; }
}Browser Support and Fallbacks
steps() with all jump keywords: supported everywhere. No concerns. linear() shipped in Chrome 113 (May 2023), Safari 17 (September 2023), and Firefox 112 (April 2023). As of mid-2026, global support is above 95%. You're fine to use it without a fallback for most projects.
Honestly, if you need to support some legacy WebView or an older Safari, the graceful fallback is just providing a cubic-bezier() first and overriding with linear() where supported. The animation won't bounce but it also won't break.
.card {
/* Fallback for anything that doesn't understand linear() */
transition: transform 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
@supports (animation-timing-function: linear(0, 1)) {
.card {
transition: transform 500ms var(--spring-bounce);
}
}The @supports check with linear(0, 1) is the canonical feature detect. It's a minimal valid linear() call — two values, linear interpolation — and it works reliably across engines.
Practical Recipes for UI Components
Here are four easing values that cover the patterns you'll reach for constantly. All generated with the Jake Archibald tool or hand-tuned, ready to drop into a :root block.
:root {
/* Gentle spring — card hovers, tooltips */
--ease-spring-light: linear(
0, 0.5 33.3%, 1.05, 1.1, 1.05, 1
);
/* Aggressive spring — modals, drawers entering */
--ease-spring-heavy: linear(
0, 0.009, 0.035 2.1%, 0.141, 0.281 6.7%,
0.723 12.9%, 0.938 16.7%, 1.017, 1.077, 1.106,
1.107 24.5%, 1.077 27.2%, 1.001 35.1%,
0.981, 0.975 41%, 1.001 49.4%, 1
);
/* Snap out — menu items, chips, badges */
--ease-snap-out: steps(6, jump-end);
/* Elastic bounce — playful CTAs, success states */
--ease-elastic: linear(
0, 0.3, 0.8, 1.15, 1.25, 1.15, 0.95, 1.03, 1
);
}The --ease-spring-heavy value is what makes a modal feel like it has weight without needing Framer Motion's spring type. Use it on transform only — applying spring-like easing to opacity or color looks wrong because those properties don't have physical analogues.
If you're building interactive components and want ready-made implementations to study, browse components on Empire UI — particularly the animation examples in the aurora and cyberpunk style categories show these timing functions in real use. The css-keyframe-guide article covers the @keyframes side of this more deeply if you need a refresher.
For tools that generate the raw values, the gradient generator and box shadow generator on this site follow a similar pattern — live-preview, copy-paste output — which is exactly the workflow you should use for building your easing library.
FAQ
Yes. linear() works with animation-timing-function in scroll-driven animations the same way it does with time-based ones. The curve is applied to the scroll progress fraction instead of elapsed time.
There's no hard spec limit, but browsers handle up to a few hundred without issue. In practice, 20–50 points covers every realistic easing curve you'd want.
Yes, transition-timing-function accepts linear() exactly like animation-timing-function does. Works the same for both.
No measurable difference in practice. Both are evaluated at parse time and run on the compositor thread. Reach for whichever gives you the curve you actually want.