Core Philosophy
- Springs are the easiest route to motion that feels physical. A Bézier curve has a narrow band of settings that look natural and a wide band that look wrong, while a spring looks plausible at almost any setting, because it is modelled on the physics your eye already knows
linear()is how springs ship in plain CSS. It plays back a pre-computed spring, it does not simulate one, and that distinction drives every limitation below- Treat springs as design tokens. A product needs three or four named springs with fixed durations, not a bespoke curve per component
- The judgement call of spring versus easing curve, and how JS libraries handle springs, lives in
animation-and-motion.md. This doc is the CSS implementation layer
Where cubic-bezier runs out
cubic-bezier()gives you exactly two control points, which buys one change of pace: accelerate then decelerate, in some proportion- Output values may leave the 0 to 1 range, so a single overshoot past the target and back is possible. What is not possible is oscillation: a curve that crosses the target, comes back, crosses again, and settles. Bounce needs more inflections than two control points can express
- The practical symptom: any motion meant to read as "arrives with energy and settles" tops out at one soft overshoot. Anything springier needs a different tool
Spring parameters, perceptually
A spring is configured by physical properties, not by curve geometry.
- Stiffness (some libraries call it tension) sets how much energy the spring carries. High stiffness arrives fast with leftover energy that bleeds off as bounce. Low stiffness glides, and reads much like a deep ease-out curve
- Damping (also called friction) sets how fast that energy drains. High damping is motion through syrup: smooth, no bounce. Low damping is bouncy. Zero damping never settles, which is exactly the case CSS cannot represent, since a transition needs a finite duration
- Mass is the weight on the end of the spring. Leave it at 1. Everything you need is reachable through the other two
- Tuning order: hold damping at a middle value and adjust stiffness until the overall speed feels right, then adjust damping to set the character, from bouncy to lush
- Note what is missing: duration. A real spring runs until the physics settle. Keep that in mind, because it is the first thing
linear()takes away
The linear() easing function
linear()with parentheses is not thelinearkeyword. The keyword is constant speed; the function is a general-purpose easing that can approximate any curve- It takes a list of progress stops and connects them with straight line segments. Enough points and the polyline reads as a smooth curve
- Each stop is an output value, where 0 is the start state and 1 is the target. Values outside that range overshoot: a stop of
1.2means 20% past the target at that moment - With bare numbers the stops spread evenly across the duration. An optional percentage after a value pins it on the time axis, so points can cluster where the curve bends and thin out where it is flat
.sheet {
transition: translate 600ms linear(0, 0.4 8%, 1.06 30%, 0.98 55%, 1);
}
- That list is illustrative. A convincing spring needs on the order of dozens of stops; a sparse list moves the element between visible waypoints instead of bouncing
Generating the stops
- Never write the list by hand. Run a spring simulation and sample it: step the physics, record position over time, emit each sample as a
value percent%pair - Online generators exist that do exactly this, taking stiffness, damping and mass, returning an optimised
linear()string plus the duration the spring needs to settle. Use one, and record the input parameters in a comment so the curve can be regenerated later - The core of a sampler is small if you want to own it:
let position = 0;
let velocity = 0;
const stops = [];
for (let t = 0; t <= duration; t += step) {
stops.push(`${round(position)} ${round((t / duration) * 100)}%`);
const acceleration = stiffness * (1 - position) - damping * velocity;
velocity += acceleration * step;
position += velocity * step;
}
-
Record each sample before stepping the physics, so the list opens with a true
0 0%instead of one step past it -
Simplify the output: drop samples on near-straight segments and keep them dense around the bounces. Half the stops can carry the same fidelity
What linear() cannot do
- It is time-based. The stops are baked for one specific duration, the time the simulated spring needed to settle. Store that duration with the curve and do not retime it. Playing the same stops over a different duration is sped-up or slowed-down footage of a spring, and it reads as fake
- Zero damping is unrepresentable. An oscillate-forever spring has no finite duration to bake into
- Interrupts snap. Reversing a transition mid-flight does not rewind the old one. The browser starts a new transition, reuses the same timing function, and scales the duration by what the spec calls the reversing shortening factor: the fraction derived from the output the old transition had reached, not from the time it had spent. Cut a transition off at 30% of its output and the reverse travels 30% of the distance in roughly 30% of the duration, running the entire timing function again inside that window. For a spring, that means the whole oscillation pattern is compressed into the shorter window, so the rebound feels artificially tight. There is no velocity preservation: the element turns around instantly instead of carrying its momentum through the direction change. This same mechanism is what makes early-interrupted springs work for boops, covered in hover-and-boop-patterns
- Long stop lists are cheaper than they look. Playback speed is unaffected by the number of stops, and a long run of short decimals compresses well, so the transfer cost is small. The only real cost is duplication: pasting the same giant string into many declarations. The token pattern below removes that, which leaves no reason to skimp on stops
spring()is not the way out yet. A native spring timing function has been proposed at the CSS working group and prototyped in at least one engine, but it is not in the standard easing-function grammar and is not broadly available. Generatedlinear()stops remain the portable approach. Check current support before betting on the native function
Springs as design tokens
The shippable pattern: each spring is a pair of custom properties, the curve and its duration, declared once at the root.
:root {
/* fallback approximates the spring's character */
--spring-bounce: cubic-bezier(0.3, 1.4, 0.4, 1);
--spring-bounce-duration: 700ms;
--spring-glide: cubic-bezier(0.25, 1, 0.2, 1);
--spring-glide-duration: 500ms;
}
@supports (transition-timing-function: linear(0, 1)) {
:root {
/* stiffness: 220, damping: 16 */
/* prettier-ignore */
--spring-bounce: linear(0, 0.31 4.1%, 0.84 8.9%, 1.12 13.4%, 1.15 16.2%, /* ... */ 1);
}
}
.sheet {
transition: translate var(--spring-bounce-duration) var(--spring-bounce);
}
- The naive fallback, two
transitiondeclarations where the second uses the spring variable, does not work. Browsers validate declarations syntactically without resolving custom properties, sovar(--spring-bounce)is accepted everywhere regardless of what the variable holds, and the second declaration always wins @supportsis the mechanism that actually asks the parser. A browser that cannot parselinear(0, 1)fails the condition, skips the block, and keeps thecubic-beziervalue.linear()is in all current major browsers, and the fallback covers the rest- Components consume the pair and never think about support. Pick a fallback Bézier that matches the spring's vibe at the same duration: one soft overshoot is the closest a Bézier gets to a bounce
- Keep the token set small. If most animations reach past the global springs for bespoke curves, the tokens are wrong; fix the tokens rather than multiplying one-offs
- Annotate each curve with the stiffness and damping that produced it, and keep the stop list on one line with a formatter-ignore comment. Nobody edits these values by hand, so a 40-line pretty-printed list is pure scroll cost
When to step up to a JS spring library
- The moment interruption matters,
linear()is the wrong layer. A JS spring is a live simulation: retarget it mid-flight and it carries current velocity into the new motion, which is what makes the result believable - Concretely, reach for a library when the motion is gesture-driven (a drag release should feed its velocity into the spring), rapidly re-triggerable (a toggle the user can hammer), or retargeted while moving (a follower chasing a moving point)
- For enter and exit transitions, hovers, and one-shot state changes, the CSS pair is lighter and runs off the main thread. That is most of a product's motion
- The split that holds up:
linear()tokens by default, a JS spring for the handful of surfaces where interrupts and velocity are the point - Which JS library, and how each one handles velocity through a retarget, is in motion-hooks
Reduced motion
- Bounce and overshoot are exactly the kind of movement the reduced-motion preference asks to remove. A spring token set needs a reduced path, not just each consumer remembering to opt out
- The token pattern makes this a one-place fix, and the fix goes in the direction that fails safe: the calm pair is the root default, and the spring values are declared inside
@media (prefers-reduced-motion: no-preference). The feature has exactly two values, so a browser that cannot evaluate the query matches neither and keeps the short ease-out. Written the other way round, that browser gets the full bounce.accessibility.mdhas the argument in full - One place still means every pair, not just the loudest one. The root default gives both bounce and glide a calm curve and a short duration, and the
no-preferenceblock restores both. Override one pair and forget the other and half the interface still springs
:root {
--spring-bounce: ease-out;
--spring-bounce-duration: 150ms;
--spring-glide: ease-out;
--spring-glide-duration: 150ms;
}
@media (prefers-reduced-motion: no-preference) {
:root {
--spring-bounce: cubic-bezier(0.3, 1.4, 0.4, 1);
--spring-bounce-duration: 700ms;
--spring-glide: cubic-bezier(0.25, 1, 0.2, 1);
--spring-glide-duration: 500ms;
}
@supports (transition-timing-function: linear(0, 1)) {
:root {
/* stiffness: 220, damping: 16 */
/* prettier-ignore */
--spring-bounce: linear(0, 0.31 4.1%, 0.84 8.9%, 1.12 13.4%, 1.15 16.2%, /* ... */ 1);
}
}
}
- Consumers are untouched.
.sheetstill readsvar(--spring-bounce)andvar(--spring-bounce-duration), and gets whichever pair the cascade resolved - Keep the end state intact. Reduced motion means the sheet still opens and the badge still appears, just without the flight. Never hide the content itself behind the preference
Common mistakes
- Hand-authoring
linear()stops, which produces robotic waypoint motion instead of a bounce - Using duplicate declarations as the fallback path, which silently ships the broken variable to old browsers instead of the Bézier
- Pasting a full stop list into each component rather than referencing a token, which is the only genuine cost of a long stop list, and the one you control
- Thinning the stops around the bounces to save bytes, which costs real fidelity for a saving compression had already made. Thin the near-straight segments instead
- Reaching for a native
spring()timing function, which is not broadly available - Changing the duration under a baked spring to speed it up, instead of regenerating the curve with higher stiffness
- Shipping a
linear()spring on a control that is constantly interrupted, where the compressed-rebound artifact shows up most - Confusing the
linearkeyword with thelinear()function when reading or reviewing CSS - No reduced-motion path: overshoot and bounce are the first things motion-sensitive users need gone, so declare the short ease-out pair as the root default and let the spring values apply only inside
@media (prefers-reduced-motion: no-preference)