opsinjs
FoundationsToken familiesMotion

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.

TokenWhat it controlsUsed 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-snap283msSettle 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-settle382msSettle 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-calm550msSettle 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-sheet483msSettle 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-instant80msState change with no travel: hover tint, focus ring, checkbox tick.no component
--opsin-duration-fast140msSmall 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-base220msThe default for chrome that is not spring-driven.dialog, sheet
--opsin-duration-slow360msLayout change: a list reflowing, a card expanding.no component
--opsin-duration-deliberate560msThe 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-shimmer1600msOne 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 one linear() value, and there is no second declaration behind it. There could not usefully be: a custom property accepts linear(…) 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: reduce each spring easing resolves to the plain linear keyword and its paired duration drops to the fallback that spring's own entry declares. That fallback is 0ms for 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.
  • 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.

On this page