opsinjs
HandbookPackages

@opsinjs/motion

Spring solving and CSS linear() generation as a callable API, plus the reduced-motion contract expressed as code rather than as advice.

Overview

opsinjs expresses springs as CSS. A spring is defined by its physical parameters and then sampled into a linear() easing with enough stops that the browser can run it on the compositor with no JavaScript, no animation library, and no main-thread work. Those parameters are stiffness, damping and mass.

That sampling is arithmetic, and it is the arithmetic this package exposes. The reasoning behind the approach is Springs as tokens; this page is the callable form.

Nothing is published. The implementation lives in this repository and produces the spring tokens in app/globals.css today.

The proposed API

// PROPOSED. Not implemented. Subject to change without a deprecation cycle.

export interface Spring {
  stiffness: number
  damping: number
  mass?: number        // default 1
}

/** Sample a spring into a CSS linear() easing. */
export function toLinear(spring: Spring, options?: {
  stops?: number       // default chosen for smoothness against stylesheet size
  epsilon?: number     // settle threshold
}): string

/** How long the spring takes to settle, in milliseconds. */
export function settleTime(spring: Spring, epsilon?: number): number

/** Position at time t, for plotting a curve. */
export function sample(spring: Spring, t: number): number

/** The reduced-motion form of any easing: instantaneous, still a state change. */
export function reduced(easing: string): string

Why a spring and not a Bézier

A cubic Bézier is defined by how it looks. A spring is defined by how it behaves.

For interface motion the difference shows up when something is interrupted. A Bézier being replayed from a new position restarts its shape and produces a visible jolt; a spring has velocity and continues from wherever it was. In a health app the interruptible cases are the ones that matter. Examples are a value updating while a sheet is still opening, and a status changing while its pill is animating.

The trade is that a spring cannot be typed into a stylesheet by hand. linear() is the bridge: the browser gets a plain easing function it can run cheaply, and the source of truth stays the physical parameters. This is the same reason the tokens are generated rather than authored.

The reduced-motion contract

reduced() encodes a rule the CSS already implements:

@media (prefers-reduced-motion: reduce) {
  :root {
    --opsin-duration-base: 1ms;
    --opsin-ease-spring: linear(0, 1);
  }
}

A state change still happens. It simply happens at once. Durations collapse to 1ms rather than to zero, so transition events still fire and code that waits for them does not hang. Springs collapse to linear(0, 1). Opacity cross-fades are kept, because they do not move; translation and scale are what cause trouble.

This is a contract rather than a preference, and stating it as a function makes it testable. The observable version is <MotionDemo> on Reduced motion, where the fallback can be watched rather than described.

What it is intended for

Generate motion tokens in a fork

toLinear() is what scripts/build-tokens.mts calls. A fork tuning its own springs needs it.

Plot a curve

sample() drives <MotionCurve>, which plots a token from its parameters so that a reviewer can see the overshoot rather than infer it from a string of numbers.

Assert a settle time

settleTime() in a test. A spring that takes 900ms to settle is not a bug in the maths; it is a design mistake, and a test is the cheapest place to catch it.

Choose a duration honestly

Motion in a health interface has an upper bound that has nothing to do with taste: somebody waiting to see whether a reading is in range should not wait for an animation. Motion in health UI sets the rule; this gives you the number.

What it deliberately will not do

It is not an animation library. It does not run animations, does not manage state, does not orchestrate sequences, and has no React surface. It produces strings and numbers that CSS consumes.

That boundary is what keeps opsinjs' motion running on the compositor. A package that animated things would pull a runtime into every product that imported it, and the whole argument for linear() is that no runtime is needed.

Verify it worked

The generated easing matches the shipped token

Run toLinear() on the documented spring parameters and compare against --opsin-ease-spring in app/tokens.generated.css. They must be identical strings.

The curve is monotone where it should be

A spring with critical damping should not overshoot. Assert it on sample() rather than looking at it.

Reduced motion still changes state

Under prefers-reduced-motion, the element must still arrive at its final state, and any transition-end handler must still fire. A "reduction" that removes the transition entirely breaks code that waits for it.

Troubleshooting

npm install @opsinjs/motion fails. Nothing is published yet.

My linear() string is enormous. Too many stops. There is a point past which extra stops are imperceptible and merely make the stylesheet unreadable and undiffable.

The animation looks wrong in Safari. Check linear() support against the tested floor on Browser support; the documented degradation is a plain easing, not no animation.

Prettier reformatted my spring token one stop per line. That is why app/globals.css is in .prettierignore. Keep generated easings out of the formatter's path.

Next

On this page