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.
Overview
Choreography is what happens when more than one element has somewhere to be. It is the difference between an interface that appears to rearrange itself and one that appears to shatter and reassemble.
The organising idea is continuity: an element that exists before a transition and after it is the same element, and should be seen to move rather than to disappear and be replaced by something similar. Almost every choreography rule below is a consequence of that one, and the health-specific ones are consequences of the fact that on these screens the thing being moved is often a number somebody is anxious about.
Choreography is often confused with stagger. Stagger is one technique inside it, and it is the technique most often used badly.
How it works
Morph, do not swap. When a card opens into a detail view, the card becomes the detail view: its container grows, its title stays put and grows with it, its value stays put. It does not fade out while a new screen fades in. The reader's eye stays on the number the whole way, and that is the point, because they tapped it wanting to know more about it and should never have to find it again on the other side.
Practically this means a transition is authored as one element changing wherever the two states share a real element, and as an enter/exit pair only when they genuinely share nothing.
Depth on overlay, not translation across. A surface arriving on top of another is a change in depth. The arriving surface comes forward; the surface underneath recedes slightly and dims. Nothing slides sideways, because sideways means "you have gone somewhere else" and the reader has not. They are still on the results screen, with a sheet over it, and the interface should say so. This also makes the way back obvious, and it is what the layering contract is expressing visually.
One thing moves at a time. If two regions of the screen both animate, the reader has to choose which to watch and will usually choose wrong. Where a transition genuinely involves two regions, one leads and the other follows, with the follower starting as the leader settles rather than at the same instant.
Stagger is for lists, is short, and is capped. A list of tiles appearing may stagger, because a small delay between items in reading order expresses the order the reader should scan them in. Three rules keep it from becoming a performance: the per-item delay is a small fraction of the item's own duration; the total stagger is capped so a long list does not take a second to appear, with items past the cap arriving together; and staggering never applies to something already on screen. A list that re-staggers on every filter change is exhausting.
Order carries meaning, so order by importance. On a health screen, if anything staggers, the reading arrives before the chart and the chart arrives before the history. Never the reverse, and never in DOM-convenient order that happens to put the least important thing first.
Exits do not choreograph. Everything leaving leaves together, quickly. There is no reason to stagger a dismissal, and a staggered exit is the animation most likely to still be running when the next screen wants to start.
Using it
Ask what the reader is following. Good choreography has exactly one subject. If you cannot say which element the reader's eye is meant to be on, the sequence has more than one and needs cutting.
Do
Tapping a blood-pressure tile grows it into the reading screen: the value keeps its position and scale relationship, the status colour carries through, and the surrounding tiles recede. The number never leaves the screen.
Don’t
The tile fades out, a spinner appears, and a new screen fades in with the same number in a different place. The reader loses the value, is shown a loading state for data that was already on screen, and has to re-find the figure they tapped.
Do not choreograph a change in a value. Continuity is for position and container, not for content. If a reading updates to a new number, the number is replaced cleanly and in place, with no morph between digits. A morphing digit implies the value passed through the intermediate states, and it did not.
Do
A new reading replaces the old one in place, with a brief crossfade, and the timestamp beneath it updates at the same moment so the reader can see why.
Don’t
Digits rolling from the old value to the new one like an odometer. It is a lovely effect and it draws a sequence of readings that were never taken.
Do not choreograph around a status change. Nothing sweeps, ripples or cascades outward from a tile that has become urgent. Escalation is colour, icon and word, and a choreographed escalation is motion carrying urgency by the back door. See Motion, health rule 1.
Keep the whole sequence inside a single perceptual moment. A transition that takes longer than roughly the time it takes to look from one side of a phone to the other stops being a transition and becomes a scene. If a sequence needs more time than that, it is doing too much.
Tokens
Choreography consumes the same durations and easings as everything else, plus the
stagger step and its cap. All of them are generated from tokens/motion.json by
scripts/build-tokens.mts.
| 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
- Large-area coordinated movement is the highest vestibular risk in the system. Several elements moving in concert across a large part of the viewport is precisely the pattern that provokes symptoms, which is why depth-on-overlay is a small scale change and why nothing pans the screen.
- Stagger delays the last item, and somebody is reading the last item. Under reduced motion the stagger is removed entirely and everything appears together; the reading order is then carried by the DOM, where it belonged all along.
- Continuity must not be the only signal of identity. A morph tells a sighted reader that this detail view is that tile. A screen-reader user gets that from the heading, the accessible name and the announcement on arrival. All three must be correct with the animation removed.
- Focus moves once, at the end. A morphing transition must not move focus part-way through, and must land it on the new surface's heading or first control when it completes. Focus that moves mid-sequence is how somebody using a screen reader ends up narrating an element that no longer exists.
- Nothing in a sequence may block input. Each stage accepts interaction, and interrupting the sequence reverses it rather than queueing another.
Related
- Using motion has the five jobs the elements in a sequence are each doing, and their durations.
- Layering and elevation is the stacking contract that depth-on-overlay is the visual expression of.
- Reduced motion says what a morph, a stagger and a depth change each become when a reader has asked for less.
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.
Reduced motion
What each motion token becomes when a reader asks for less movement. A stated reduced variant per token, not a kill switch that leaves things teleporting.