@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): stringWhy 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
- Springs as tokens is the reasoning.
- Reduced motion is the contract, observable.
- Motion in health UI says when not to animate at all.
@opsinjs/color
The colour engine as a callable API, covering OKLCH, gamut mapping, APCA and WCAG 2.2, and deriveTheme(). The check that gates this site can gate your CI too.
@opsinjs/react
The components as a conventional npm dependency, for teams that cannot vendor source. The trade is stated plainly before you take it.