---
title: "@opsinjs/motion"
description: "Spring solving and CSS linear() generation as a callable API, plus the reduced-motion contract expressed as code rather than as advice."
url: "https://opsinjs.pensievelabs.org/packages/opsinjs-motion"
source: "https://opsinjs.pensievelabs.org/packages/opsinjs-motion.md"
section: "Other pages"
kind: "guide"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["spring solver", "linear() generator", "motion library", "easing maths"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<PageTemplate kind="guide" />

## Overview [#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](../foundations/motion/springs-as-tokens.mdx); this page is
the callable form.

<StubNotice
  name="opsinjs-motion"
  issue="prashantonomy/opsinjs#0"
  questions="[
  &#x22;How many stops is the right default, one that is enough to be smooth and few enough to be readable in a stylesheet?&#x22;,
  &#x22;Should the solver accept duration-and-bounce as well as stiffness-damping-mass?&#x22;,
  &#x22;Does the reduced-motion collapse belong in this package, or is it purely a CSS concern?&#x22;,
]"
/>

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

## The proposed API [#the-proposed-api]

```ts
// 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 [#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 [#the-reduced-motion-contract]

`reduced()` encodes a rule the CSS already implements:

```css
@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](../foundations/motion/reduced-motion.mdx), where the fallback
can be watched rather than described.

## What it is intended for [#what-it-is-intended-for]

<Steps>
  ### Generate motion tokens in a fork [#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 [#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 [#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 [#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](../health/motion-in-health-ui.mdx) sets the
  rule; this gives you the number.
</Steps>

## What it deliberately will not do [#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 [#verify-it-worked]

<Steps>
  ### The generated easing matches the shipped token [#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 [#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 [#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.
</Steps>

## Troubleshooting [#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](../start/browser-support.mdx); 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 [#next]

* [Springs as tokens](../foundations/motion/springs-as-tokens.mdx) is the
  reasoning.
* [Reduced motion](../foundations/motion/reduced-motion.mdx) is the contract,
  observable.
* [Motion in health UI](../health/motion-in-health-ui.mdx) says when not to
  animate at all.
