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.
Overview
Motion in a consumer health product has a different job from motion in a marketing site or a productivity tool. Nobody opens a blood-pressure app to be delighted. They open it because they want to know whether a number is a problem, and every millisecond of animation between them and that answer is a millisecond they spend not knowing.
So the position opsinjs takes is: motion exists to explain where things went, and nothing else. It shows that a sheet came from the button you pressed, that this list item is the one you tapped, that the thing which just appeared is on top of rather than instead of what was there. It never celebrates, never emphasises, never carries urgency, and never delays an answer.
This is the third of the three things opsinjs does that other systems do not, and
the reason is mechanical rather than aesthetic: opsinjs ships springs as
tokens. A spring is a physical description of stiffness, damping and mass. CSS
has no spring function, so most systems either approximate one with a cubic
Bézier and lose the character, or animate in JavaScript and lose the ability to
express it as a token at all. The linear() easing function makes the third
option possible, and that is what Springs as tokens is
about.
The thing motion is most often confused with is feedback. Feedback is the interaction state: the press, the focus ring. Motion is how the interface gets from one arrangement to another. A component can have excellent feedback and no motion at all, and on many health surfaces it should.
How it works
Four pages sit under this one and they answer four different questions:
- Springs as tokens says what a curve is made of.
Spring parameters in
tokens/motion.json, sampled into alinear()easing at build time, so the token in the stylesheet is a real spring rather than an impression of one. - Using motion says which curve, and how long. Motion chosen by job: enter, exit, feedback, navigation, loading. Duration is a property of the job and the distance, not of the designer's mood.
- Choreography says what moves together. Sequencing, stagger, continuity, and the rule that a thing which persists should morph rather than be swapped.
- Reduced motion says what happens when someone has said no. Stated per token, not as a blanket kill switch, because "no animation at all" is a worse experience than a considered reduced variant.
Underneath all four sit four rules that are specific to health and are not negotiable at the component level.
1. Urgency is never carried by motion. Nothing pulses, flashes, throbs or shakes to indicate that a value needs attention. Attention is carried by colour, by an icon and by a word, which are the three channels from Principles, because those survive a still screenshot, a printout, a screen reader and a reader who has switched motion off. A pulsing red tile tells nobody anything they were not already told, and it tells a person with a vestibular disorder to close the app. The doctrine is on Motion in health UI.
2. No overshoot on a clinical value. A springy, bouncy arrival is charming on a to-do app and dishonest on a reading: a number that settles by wobbling past itself has visually asserted values it never had. Any element whose content is a measurement, a range position or a status uses a critically damped curve with no overshoot. The bouncy tokens exist, and they are for chrome.
3. A count-up happens once, and lasts no more than 600 ms. Animating a number from zero to its value is legitimate on first reveal. It draws the eye to the thing the screen is about. It is not legitimate on a refresh, on a return to the screen, or on any subsequent render, because an unchanged number that animates implies a change that did not happen. And it is bounded: past about 600 ms a reader who wants the number is being made to wait for a decoration.
4. Depth on overlay, not translation across. When a surface arrives over another, the one underneath recedes with a small scale reduction and a dim, rather than the whole screen sliding sideways. Sliding implies you have gone somewhere; depth implies something is on top of where you already are, which is what actually happened, and it makes the back gesture obvious. This is the mechanism behind the layering contract.
Using it
Start from none. The default state of any new surface is no animation. Add motion when you can name the thing it explains, such as "this sheet came from that row", and remove it when you cannot. A transition that exists because the surface looked bare without one is a transition the reader pays for and gets nothing from.
Do
A result sheet grows from the row that opened it, with the list behind it dimming and receding slightly. The reader can see where it came from and where it will go back to.
Don’t
The same sheet fades in over the middle of the screen while the reading behind it pulses to draw attention to itself. Nothing is explained, the pulse is a second urgency signal that duplicates the status colour, and a reader with motion sensitivity now has two reasons to leave.
Never animate to slow a reader down. If a piece of information matters enough that you want to make sure it is seen, the answer is placement, wording and status, rather than a 900 ms entrance that holds the reader hostage until it finishes.
Interruptible means transitions, not keyframes. A reader can act mid-animation anywhere they can scroll, tap or go back. There, use CSS transitions between two states rather than a keyframe sequence. A transition reverses cleanly from wherever it currently is; a keyframe animation restarts, jumps, or plays to the end while the reader waits. The one place keyframes are correct is a genuinely continuous loop with no end state, such as a loading shimmer.
Budget it. A screen should have at most one thing moving at a time. Two things moving are a choreography problem, and three are a bug. See Choreography.
Tokens
Motion owns durations, easings and the spring parameters the easings are derived
from. Everything below is generated from tokens/motion.json by
scripts/build-tokens.mts; the generated page with live curve previews is
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
Motion is the foundation with the most direct route from a design decision to somebody feeling physically unwell, and the second most direct route to somebody missing information entirely.
- Vestibular effects are real and are triggered by large-area movement, particularly parallax, zoom and anything that moves a substantial portion of the viewport. This is why depth-on-overlay is specified as a small scale change and why nothing in opsinjs moves the whole screen.
prefers-reduced-motionis honoured per token, not by disabling everything. A reduced variant still communicates where things came from, using opacity and very short distances. The full contract is on Reduced motion and the conformance position is on Accessibility → Reduced motion.- Nothing flashes. No opsinjs surface animates at a rate that could approach the flash thresholds in WCAG 2.2 SC 2.3.1, and nothing flashes at all. The prohibition on motion as an urgency channel removes the only reason anybody would want it.
- Nothing that moves is also the only way to read something. Any content that animates is fully readable in its final state, and its final state is what is exposed to assistive technology.
- Motion never gates input. Controls are usable during an entrance animation; a reader who knows where the button is does not have to wait for the interface to finish arriving.
Related
- Springs as tokens is the mechanism that makes a physical curve survive as a CSS custom property.
- Motion in health UI is the clinical doctrine behind rules 1 to 3, with the reasoning stated as opinion where it is opinion.
- Handbook → Motion in practice shows
how to write these transitions yourself against
data-starting-styleanddata-ending-style.
Material tokens
The generated reference for all six rungs covers eight properties each, both themes, and the preference overrides that replace them.
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.