Using motion
Motion chosen by the job it does, with duration derived from distance. The five jobs are enter, exit, feedback, navigation and loading, plus the count-up rule.
Overview
The usual way to choose an animation is to pick a duration that feels right and a curve that looks nice. That produces a system where every component is individually plausible and the whole is incoherent: a sheet that takes 400 ms next to a menu that takes 150 ms, for no reason either author could state.
opsinjs chooses by job. There are five, they behave differently, and each one has a defensible reason for its curve and its duration. Once you have named the job, the token follows, and the argument is over.
The related idea people reach for instead is a t-shirt scale of durations named
fast, medium and slow. opsinjs has those steps, but they are the output
of the decision, not the input. Choosing slow because a transition felt
rushed is how a system ends up with a 560 ms press feedback.
How it works
Enter. Something arrives that was not there. Decelerating: fast at the start, settling at the end, so the eye catches the movement and then the destination. Enter transitions may overshoot when the arriving thing is chrome, and must not when it contains a value. Enter is also the only job allowed to be the longest, because the reader is being told where something came from and that takes a moment to read.
Exit. Something leaves. Accelerating and shorter than its enter. Its
duration is usually around two thirds of the enter's. Nobody needs to watch
something they have dismissed; a symmetrical exit reads as the interface
arguing with you. Exit is also where data-ending-style matters, because the
element must survive long enough to animate before it is removed from the DOM.
The mechanics are in
Handbook → Motion in practice.
Feedback. The interface acknowledging your touch. The shortest thing in the
system, on the instant step, applied on pointer-down rather than on click.
Anything longer than a blink stops reading as acknowledgement and starts reading
as lag. On a form where somebody is entering a blood-pressure reading, that is
the difference between one entry and two.
Navigation. Moving between screens or between levels of one. This is where choreography lives, and where the depth-on-overlay rule applies: a surface arriving over another pushes the one underneath back rather than sliding it aside.
Loading. The only job that legitimately loops, and therefore the only one that legitimately uses keyframes. A shimmer or a progress indicator has no end state to transition to. Everything else has two states and belongs in a transition.
Duration is a function of distance, not of importance. A thing that moves
across the screen takes longer than a thing that moves four pixels, because at a
constant duration the long move looks slow and the short move looks like a
glitch. In practice this means the scale is used as: instant for feedback,
fast for small local changes and exits, base for most enters and in-place
changes, slow for a full-width or full-height surface, and deliberate for
the count-up described below and almost nothing else.
Using it
Name the job before you pick a token. If you cannot say which of the five it is, the transition probably should not exist.
The count-up rule. Animating a number from zero to its value is permitted exactly once per value, on first reveal, and for no longer than 600 ms.
- Once means on the first render of that value on that screen. A pull to refresh that returns the same number does not re-animate it; a return from a background tab does not re-animate it. An unchanged number that animates is telling the reader something changed.
- 600 ms is a ceiling, not a target. It is opinion rather than a measured threshold, and the reasoning is simple: the count-up exists to draw the eye to the figure the screen is about, and past about half a second the reader who already found it is being made to wait for a decoration.
- The number is present and correct in the DOM from the first frame. What animates is the presentation; assistive technology is given the final value immediately and never the intermediate ones.
Do
A daily step count that counts up once when the screen first appears, finishing well inside 600 ms, with the final figure in the accessible name from the start.
Don’t
A resting heart rate that counts up every time the tab regains focus. Nothing has changed, the animation says something has, and a reader watching their own heart rate is the last person who should be given a false signal of change.
Transitions, not keyframes, wherever a reader can interrupt. Anywhere somebody can scroll, tap, dismiss or navigate mid-animation, use a CSS transition between two states. That is nearly everywhere. A transition reverses smoothly from wherever it has got to; a keyframe animation either restarts from the beginning, snaps, or holds the reader until it finishes. The single exception is the looping loading indicator, which has no end state.
Do
A sheet that transitions between closed and open. Start to drag it back halfway through the opening and it follows your finger from where it is.
Don’t
The same sheet as a keyframe animation. Interrupting it makes it jump to the start or lurch to the end. A bottom sheet is the surface most likely to be grabbed mid-flight, and that is where the failure is most noticeable in the system.
Never put information only in the motion. If the only way to know two things are related is to have watched them animate, the relationship is invisible to anybody who arrived after it finished, took a screenshot, or has reduced motion on.
--opsin-ease-spring-settle · --opsin-duration-spring-settle. Under prefers-reduced-motion: reduce this becomes 1ms with a flat curve. The element still moves, and it simply arrives at once. The checkbox simulates the preference; if you have actually set it, app/globals.css has already applied it and the two states will look the same.
Tokens
Durations and easings are generated from tokens/motion.json by
scripts/build-tokens.mts. Which job maps to which step is documented in the
What it controls column of the generated table rather than restated here.
| 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
- Feedback timing is a motor-accessibility issue. A press acknowledgement that arrives late reads as a missed tap, and a reader with a tremor or reduced dexterity will tap again. On a logging surface, that means a duplicate entry. This is why feedback is on the shortest step and fires on pointer-down.
- Interruptibility is an accessibility requirement, not a polish item. Somebody using a switch or a screen reader may act at any point in an animation. A transition handles that; a keyframe sequence often does not.
- An animated number must not be an animated announcement. The count-up is visual only. Screen readers receive the final value once. A live region that fires on every intermediate frame is unusable, and it is a common consequence of animating the text content instead of the presentation.
- Nothing may be unusable while it animates. Controls accept input during their entrance; a reader who knows where the button is does not wait.
- Every job has a reduced variant, and none of them are simply removed. See Reduced motion.
Related
- Choreography says what happens when more than one thing has a job at the same time.
- Springs as tokens is where the curves these jobs use come from, and which of them may touch a value.
- Data states is the loading job's other half: what the skeleton is allowed to look like.
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.
Choreography
What moves together and in what order. Continuity over replacement, morph rather than swap, depth rather than translation, and one thing moving at a time.