Springs as tokens
How a spring described by stiffness, damping and mass is sampled into a CSS linear() easing at build time, so a physical curve survives as a token.
Overview
Every design system has an easing token. Almost all of them are cubic Béziers, because until recently that was the only easing CSS could express. A cubic Bézier cannot describe a spring. It has no overshoot, no settle, no notion of mass. So systems that want spring physics animate in JavaScript, which means the curve stops being a token: it lives in a component, it cannot be themed, it cannot be inspected, and two components that both claim to use "the standard spring" can quietly disagree.
opsinjs takes the third path. Springs are authored in tokens/motion.json as
physics: stiffness, damping and mass. scripts/build-tokens.mts solves the
spring numerically and emits the result as a CSS linear() easing. The
stylesheet ends up holding a real spring, as a custom property, usable by any
CSS transition, with no JavaScript animation library anywhere in the tree.
This is not the same as "we have a bouncy easing". The point is the provenance: the curve in the stylesheet is derived from parameters a person can reason about and change, rather than from a Bézier somebody eyeballed until it felt right.
How it works
The physical model. A spring token is three numbers: stiffness k, damping
c, and mass m. From those come the two values that actually determine the
shape of the curve:
- the undamped angular frequency,
ω₀ = √(k/m), is how fast it wants to move; - the damping ratio,
ζ = c / (2√(km)), is how much it resists.
ζ < 1 is underdamped and overshoots. ζ = 1 is critically damped: the fastest
approach to the target with no overshoot at all. ζ > 1 is overdamped and
crawls in. The damping ratio is the number to reach for when a curve feels wrong,
and it is the one that decides whether a token may be used on a clinical value.
The solution. Normalising the motion to travel from 0 to 1 with zero initial
velocity, the displacement at time t is:
ζ < 1 x(t) = 1 − e^(−ζω₀t) · [ cos(ω_d t) + (ζω₀ / ω_d) · sin(ω_d t) ], ω_d = ω₀√(1−ζ²)
ζ = 1 x(t) = 1 − e^(−ω₀t) · (1 + ω₀t)
ζ > 1 x(t) = 1 − ( A·e^(r₁t) + B·e^(r₂t) ), r₁,₂ = −ω₀(ζ ∓ √(ζ²−1))The sampling. linear() takes a list of output values; the browser
interpolates linearly between them. So the sampler solves the spring at evenly
spaced times, normalises the time axis by the settling duration, and prints the
samples. That derivation is run by the token author, not by the build: the stop
list it produces is recorded in tokens/motion.json beside the parameters it
came from, and scripts/build-tokens.mts copies it out to CSS unchanged, so the
curve a page plots and the curve the browser runs are the same numbers:
export interface SpringToken {
/** Stiffness. Higher is faster and tighter. */
stiffness: number
/** Damping. Higher resists motion; at 2·√(stiffness·mass) it stops overshooting. */
damping: number
/** Mass. Higher is heavier and slower to start. */
mass: number
/** Settle threshold: how close to 1 counts as arrived. */
epsilon?: number
}
export interface DerivedEasing {
/** The emitted CSS value, e.g. `linear(0, 0.006, ... 1)`. */
easing: string
/** Settling time in milliseconds, computed from the physics. */
durationMs: number
/** Damping ratio. Below 1 the curve overshoots and may not touch a value. */
dampingRatio: number
}
export function springToLinear(
spring: SpringToken,
// Emits samples + 1 stops: `sampling.stops` in motion.json, minus one.
samples = 20,
): DerivedEasing {
const { stiffness: k, damping: c, mass: m, epsilon = 0.001 } = spring
const w0 = Math.sqrt(k / m)
const zeta = c / (2 * Math.sqrt(k * m))
const at = (t: number): number => {
if (zeta < 1 - 1e-6) {
const wd = w0 * Math.sqrt(1 - zeta * zeta)
return (
1 -
Math.exp(-zeta * w0 * t) *
(Math.cos(wd * t) + ((zeta * w0) / wd) * Math.sin(wd * t))
)
}
if (zeta <= 1 + 1e-6) return 1 - Math.exp(-w0 * t) * (1 + w0 * t)
const s = w0 * Math.sqrt(zeta * zeta - 1)
const r1 = -w0 * zeta + s
const r2 = -w0 * zeta - s
const A = -r2 / (r1 - r2)
const B = r1 / (r1 - r2)
return 1 - (A * Math.exp(r1 * t) + B * Math.exp(r2 * t))
}
// Settling time: the first moment the curve stays within epsilon of 1.
const step = 1 / 240
let settled = 10
for (let t = 0; t <= 10; t += step) {
if (Math.abs(1 - at(t)) < epsilon) {
settled = t
break
}
}
const stops: string[] = []
for (let i = 0; i <= samples; i++) {
stops.push(round(at((i / samples) * settled)))
}
// Pin the final stop: sampling leaves 0.9997 and a transition that ends a
// hair short of its target is a bug that only shows on some displays.
stops[stops.length - 1] = "1"
return {
easing: `linear(${stops.join(", ")})`,
durationMs: Math.round(settled * 1000),
dampingRatio: zeta,
}
}
const round = (n: number): string => String(Math.round(n * 1000) / 1000)Two details in there matter more than they look.
The settling time is computed, not chosen. A spring has no natural end; it
approaches its target forever. The sampler finds the first moment the curve
stays within epsilon of 1 and calls that the duration, which is why every
spring token ships with a duration derived from its own physics rather than one
somebody picked to match.
The damping ratio is returned alongside the curve, because it is the value that decides whether a token may animate a clinical value at all. See Using it below. Publishing it in the generated table is what makes that rule reviewable rather than a matter of looking at the curve and guessing.
The last stop is pinned to exactly 1. Floating-point sampling will otherwise leave a final stop at 0.9997, and a transition that ends a hair short of its target is a rendering bug that appears only on high-density displays and only sometimes.
--opsin-ease-spring does not resolve to a linear() value in this theme, and no spring parameters were given. Curves are generated from tokens/motion.json by scripts/build-tokens.mts.
--opsin-ease-spring. The dashed line is the target; anything above it is overshoot.Using it
Choose by damping ratio, not by feel. The tokens divide into two families and the boundary between them is a safety rule, not a taste one.
- Damped springs (
ζ ≥ 1, no overshoot) are the only curves permitted on anything whose content is a measurement, a range position, a status or a count. A value that overshoots has drawn a number the person never had. This is health rule 2 from Motion and it is enforced by review, not by the compiler, because the tokens themselves cannot know what they are animating. - Overshooting springs (
ζ < 1) are for chrome: a sheet arriving, a toggle flipping, a control acknowledging a press. They give the interface its character and they never touch a clinical number.
Do
A range marker sliding to a new position on a damped spring. It arrives, it stops, and at no point does it indicate a reading outside the range.
Don’t
The same marker on a bouncy spring, overshooting into the high band before settling back. For a fraction of a second the interface asserted a value that was never measured, and it did so on a screen whose entire purpose is to report measured values.
Do not hand-write a linear() value. They are unreadable, unreviewable and
impossible to adjust. That is the whole reason for generating them. Change the
three numbers in tokens/motion.json, resample, and regenerate.
Do not tune a spring inside a component. If a component needs a curve the scale does not have, that is a proposal for a new token, with a name and a rationale. See Contributing tokens.
Sample count is a trade. More stops means a closer fit and a longer custom
property. sampling.stops in tokens/motion.json is twenty-one, and the file
records why: below about sixteen the overshoot starts to look visibly polygonal,
and above about twenty-four the extra stops buy nothing a viewer can see while
every stylesheet that inlines the token grows. Change the number there if you
must; do not change one curve by hand to match a different count.
Tokens
The spring parameters, the derived linear() easings and the computed settling
durations are all generated from tokens/motion.json. The full table with live
curve plots is on Motion tokens.
| Token | What it controls | Used by |
|---|---|---|
| --opsin-ease-spring-snaplinear(0, 0.0715, 0.2271, 0.4053, 0.5722, 0.7119, 0.8198, 0.8978, 0.9505, 0.9836, 1.0025, 1.0118, 1.015, 1.0147, 1.0126, 1.01, 1.0073, 1.0051, 1.0033, 1.0019, 1) | Direct manipulation only: a switch the reader just flipped, a segmented control, a pressed button settling. It overshoots by 1.5%. That is enough to feel physical but not enough to look playful. The 1.5% is the largest overshoot in the system but not the only one: `spring-settle` overshoots by 0.88%. Only `spring-calm` and `spring-sheet` reach their target without passing it. | no component |
| --opsin-duration-spring-snap283ms | Settle time for the spring-snap spring, measured from its own parameters. | no component |
| --opsin-ease-spring-settlelinear(0, 0.0742, 0.2328, 0.4113, 0.5758, 0.7116, 0.8157, 0.8905, 0.9412, 0.9736, 0.9928, 1.003, 1.0076, 1.0088, 1.0082, 1.0068, 1.0053, 1.0038, 1.0026, 1.0017, 1) | The workhorse for chrome: popovers, tooltips, menus, chips appearing and disappearing. | no component |
| --opsin-duration-spring-settle382ms | Settle time for the spring-settle spring, measured from its own parameters. | no component |
| --opsin-ease-spring-calmlinear(0, 0.0829, 0.2457, 0.4157, 0.5642, 0.6832, 0.774, 0.841, 0.8893, 0.9236, 0.9476, 0.9643, 0.9757, 0.9836, 0.9889, 0.9926, 0.995, 0.9967, 0.9978, 0.9985, 1) | A health value that changes while it is already on screen: a bar re-filling from one reading to the next, a dial travelling between two values the reader has already been shown. Never a first paint and never a first reveal. A value arrives at its final figure, with no count-up, no dial sweep and no line drawing itself in (health/motion-in-health-ui rule 2). Slightly overdamped (zeta just over 1) so it never overshoots and never bounces. | no component |
| --opsin-duration-spring-calm550ms | Settle time for the spring-calm spring, measured from its own parameters. | no component |
| --opsin-ease-spring-sheetlinear(0, 0.0881, 0.2576, 0.431, 0.5798, 0.6971, 0.7854, 0.8497, 0.8958, 0.9282, 0.9508, 0.9664, 0.9771, 0.9845, 0.9895, 0.9929, 0.9952, 0.9967, 0.9978, 0.9985, 1) | Large surfaces travelling a long distance: sheets, dialogs, full-screen pushes. Overdamped, because a sheet that bounces at the top of its travel reads as a dropped object. | dialog, sheet |
| --opsin-duration-spring-sheet483ms | Settle time for the spring-sheet spring, measured from its own parameters. | dialog, sheet |
| --opsin-ease-standardcubic-bezier(0.2, 0, 0, 1) | Non-spring transitions where a spring would be overkill: colour, opacity, border. Fast out, slow in. | accordion, body-map, button, checkbox, combobox, consent-sheet, dialog, menu, number-field, popover, progress, radio-group, scale-input, scroll-area, segmented-control, select, sheet, skeleton, slider, switch, symptom-picker, tab-bar, tabs, textarea, toast, tooltip |
| --opsin-ease-entercubic-bezier(0.05, 0.7, 0.1, 1) | Something arriving from off-screen or from nothing. Decelerating, because an arrival should feel like it is coming to rest. | no component |
| --opsin-ease-exitcubic-bezier(0.3, 0, 0.8, 0.15) | Something leaving. Accelerating and shorter than its enter, because a reader does not need to watch a dismissal finish. | dialog |
| --opsin-duration-instant80ms | State change with no travel: hover tint, focus ring, checkbox tick. | no component |
| --opsin-duration-fast140ms | Small elements moving a small distance. | accordion, body-map, button, checkbox, combobox, consent-sheet, dialog, menu, number-field, popover, progress, radio-group, scale-input, scroll-area, segmented-control, select, sheet, skeleton, slider, switch, symptom-picker, tab-bar, tabs, textarea, toast, tooltip |
| --opsin-duration-base220ms | The default for chrome that is not spring-driven. | dialog, sheet |
| --opsin-duration-slow360ms | Layout change: a list reflowing, a card expanding. | no component |
| --opsin-duration-deliberate560ms | The ceiling on a transition a reader is waiting on, such as a first-run reveal or a consent sheet, where the point is that the reader notices. No such transition may exceed it. A looping placeholder period, like the skeleton shimmer, is bounded instead by its iteration count, so a single sweep of it may run longer. | no component |
| --opsin-duration-shimmer1600ms | One sweep of a loading placeholder's sheen. This is a period, the time for the sheen to make one pass across the bar, and not a travel time. At 1600ms the movement reads as calm rather than as urgency, which the first motion rule requires of anything that is not a value the reader controls. Its consumer pairs it with a finite iteration count so the total motion stays under the five seconds at which WCAG 2.2 SC 2.2.2 engages. | skeleton |
Accessibility impact
linear()degrades to the engine's default curve, not to nothing and not to a fallback of ours. A spring token is one custom property holding onelinear()value, and there is no second declaration behind it. There could not usefully be: a custom property acceptslinear(…)as an arbitrary token sequence even on an engine that cannot compute it, so the value is stored, the cascade never reaches an earlier declaration, and an author-written fallback above it would be dead code. The failure happens one step later, at substitution.transition-timing-function: var(--opsin-ease-spring-snap)is invalid at computed-value time, so the property takes its initial value and the move runs on the browser's default easing. Say what that costs and what it does not: the paired duration is a separate token and is untouched, so nothing in a sequence gets out of step, nothing is switched off and nothing disappears; what is lost is the curve, which is the whole of what a spring token carries. Which engines take that path is a runtime question rather than a remembered one, and<BrowserSupport>on Browser support tests it in the reader's own browser.- Overshoot is a vestibular consideration as well as an honesty one. Oscillation is more provocative than a monotonic move, which is a second independent reason the damped family exists.
- Reduced motion replaces the curve, not just the duration. Under
prefers-reduced-motion: reduceeach spring easing resolves to the plainlinearkeyword and its paired duration drops to the fallback that spring's own entry declares. That fallback is0msfor the spring a reader initiates and for the one that carries a health value, and a cross-fade of about a tenth of a second for the two that announce something arriving. Nothing is switched off wholesale; the per-token contract is on Reduced motion. - A spring's settling time is part of its accessibility budget. A long settle means a control is still moving when a reader tries to hit it. Every token's computed duration is published, and the ones used for anything interactive sit at the short end of the scale.
Related
- Using motion says which of these curves each job gets, and how duration relates to distance.
- Reduced motion says what every spring token becomes when a reader has asked for less movement.
- Motion tokens is the generated table, with each curve plotted from its own parameters.
Motion
Motion for a product somebody opens when worried. What it may do, the four health rules it must never break, and when the right amount of animation is none.
Using motion
Motion chosen by the job it does, with duration derived from distance. The five jobs are enter, exit, feedback, navigation and loading, plus the count-up rule.