Motion
Four spring presets (snappy, smooth, gentle, bouncy) compiled to CSS linear(), fade and exit, choreography, six signature motions, the transition catalogue, reduced motion and performance.
Model
- Springs (mass 1, described by response and damping ratio) drive position, scale and size. Parameters are compiled to a CSS
linear()easing at build time, so the browser composites them without JS. Duration is the result of the spring settling, not a parameter. - Fades handle color, opacity and shadow.
- Exits always use the exit curve: leaving is faster than arriving, and never uses a spring.
Stiffness = (2π / response)², damping = 4π · damping ratio / response.
Presets
| Preset | Tokens | Response / damping ratio | Stiffness / damping | To 95% | Settled (= duration token) | Overshoot | Use |
|---|---|---|---|---|---|---|---|
| Snappy | --spring-snappy --dur-snappy | 0.30 / 0.82 | 439 / 34.3 | 168ms | 430ms | 1% | Press, switch track, selection, indicators, focus ring, menus opening |
| Smooth | --spring-smooth --dur-smooth | 0.40 / 1.0 | 247 / 31.4 | 303ms | 570ms | 0 | Layout changes, collapsing, drawers, sidebar collapse, making room, hover lift |
| Gentle | --spring-gentle --dur-gentle | 0.52 / 1.0 | 146 / 24.2 | 394ms | 710ms | 0 | Large surfaces arriving: page content, dialogs, bottom sheets |
| Bouncy | --spring-bouncy --dur-bouncy | 0.42 / 0.62 | 224 / 18.6 | 174ms | 720ms | 8% | Arrivals worth noticing: switch thumb, count badges, success check, toast arrival |
| Fade | --ease-fade --dur-fade | cubic-bezier(0.2, 0, 0, 1) · 150ms | Color, opacity, shadow | ||||
| Exit | --ease-exit --dur-exit | cubic-bezier(0.3, 0, 1, 1) · 150ms | Every exit |
"To 95%" is when people perceive the motion as done; the settle time is longer because the tail converges slowly and is invisible.
Spring curves (CSS linear(), 49 points)
--spring-snappy: linear(0, 0.017, 0.0596, 0.1197, 0.1907, 0.2675, 0.3462, 0.424, 0.4985, 0.5685, 0.633, 0.6915, 0.7438, 0.7875, 0.8281, 0.863, 0.8928, 0.9179, 0.9388, 0.956, 0.97, 0.9811, 0.9898, 0.9965, 1.0015, 1.005, 1.0075, 1.0091, 1.0101, 1.0104, 1.0104, 1.01, 1.0094, 1.0087, 1.008, 1.0071, 1.0063, 1.0056, 1.0048, 1.0041, 1.0035, 1.0029, 1.0024, 1.0019, 1.0016, 1.0012, 1.0009, 1.0007, 1);
--spring-smooth: linear(0, 0.0164, 0.0568, 0.1125, 0.1742, 0.2426, 0.3119, 0.3797, 0.4419, 0.5029, 0.5594, 0.6111, 0.6562, 0.6986, 0.7366, 0.7704, 0.7991, 0.8257, 0.849, 0.8694, 0.8866, 0.9023, 0.9159, 0.9277, 0.9375, 0.9464, 0.954, 0.9607, 0.9661, 0.9711, 0.9753, 0.9789, 0.9819, 0.9846, 0.9869, 0.9888, 0.9905, 0.9918, 0.9931, 0.9941, 0.995, 0.9957, 0.9964, 0.9969, 0.9974, 0.9978, 0.9981, 0.9984, 1);
--spring-gentle: linear(0, 0.0151, 0.0528, 0.1032, 0.1641, 0.2272, 0.2938, 0.3596, 0.4209, 0.481, 0.5352, 0.5871, 0.6345, 0.6761, 0.7151, 0.7488, 0.7801, 0.807, 0.8317, 0.8535, 0.8721, 0.889, 0.9033, 0.9163, 0.9277, 0.9372, 0.9459, 0.9531, 0.9596, 0.9651, 0.97, 0.9742, 0.9777, 0.9809, 0.9835, 0.9859, 0.9879, 0.9896, 0.9911, 0.9924, 0.9935, 0.9944, 0.9952, 0.9959, 0.9965, 0.997, 0.9974, 0.9978, 1);
--spring-bouncy: linear(0, 0.0252, 0.0871, 0.174, 0.276, 0.385, 0.4944, 0.5993, 0.6963, 0.7802, 0.858, 0.921, 0.9721, 1.0108, 1.0416, 1.0616, 1.075, 1.0814, 1.0827, 1.0801, 1.0744, 1.067, 1.0584, 1.0496, 1.0403, 1.0317, 1.0241, 1.017, 1.0108, 1.0057, 1.0018, 0.9985, 0.9962, 0.9947, 0.9937, 0.9932, 0.9932, 0.9934, 0.9939, 0.9946, 0.9953, 0.996, 0.9968, 0.9975, 0.9981, 0.9987, 0.9992, 0.9996, 1);
--dur-snappy: 430ms; --dur-smooth: 570ms; --dur-gentle: 710ms; --dur-bouncy: 720ms;linear() support: Chrome 113+, Safari 17.2+, Firefox 112+. Elsewhere @supports not (transition-timing-function: linear(0, 1)) falls back to --ease-fade at 200 / 320ms.
Choreography
- Appear from the trigger: a popover's
transform-originpoints at its trigger; closing returns the same way. - Dispersion: items in a group appear one after another, moving outward from the source,
--stagger30ms apart, up to 8 (from the 9th on they appear with the 8th). Grids run top-left to bottom-right, menus from the trigger outward, chart series in legend order. - One main motion at a time: while a dialog opens, nothing else in the background animates.
- Keep distances short: page-level movement ≤ 16px; no full-page horizontal slides on desktop.
- Arrive slowly, leave quickly: exits use
exitat 150ms, roughly half the perceived entrance time.
Interruption and direct manipulation
- Reversible states (hover, selection, expansion, toggles) use
transition, so a new state continues from the current value; never@keyframes. - Dragging tracks the pointer 1:1; on release, pick the target from the velocity-projected end point and hand the velocity to a spring (a JS spring using the parameters above).
- Clicks are accepted while an animation runs; never ignore input because a motion hasn't finished.
Six signature motions
| Name | Trigger | Behaviour | Preset | Token / class |
|---|---|---|---|---|
| Refract | Selecting a chip, segment or list row | brand-tint spreads in a circle from the click point (clip-path: circle()); the check grows from zero width | Snappy | .chip::before, --rx --ry |
| Disperse | A group appears for the first time | Items rise 8px and fade in one after another, outward from the source | Gentle, 30ms apart | .disperse, child --i |
| Focus beam | Keyboard focus | The ring converges from 6px outside, transparent, to a 2px offset | Snappy | outline on :focus-visible, @keyframes beam |
| Sweep | Skeleton loading; success confirmation | A soft band of light passes across: skeletons loop every 1.6s (--specular); success plays once in dispersion colors, 600ms | Fade | .skel::after, @keyframes sweep |
| Lift & press | Hovering / pressing clickable cards and buttons | Hover +1 level, up 1px; press 0.97 (cards 0.99), −1 level | Smooth / snappy | .lift, .btn:active |
| Specular | Pointer moving over a clickable card | A 280px soft light that follows the pointer | Fade 150ms | .lift::after, --mx --my |
Transition catalogue
| Case | Enter | Exit | Preset | Under reduced motion |
|---|---|---|---|---|
| Page change | Content rises 8px + fades in; the shell stays still | Old content fades out in 100ms | Gentle | Fade in only |
| Tab / segment switch | The indicator slides to the new item (leading edge snappy, trailing edge smooth with a 60ms delay); content cross-fades 150ms + 4px shift in the same direction | — | Snappy | Indicator jumps; content cross-fades |
| Menu / dropdown / popover | From the trigger, 0.96 → 1 + fade in | 120ms fade + 0.98 | Snappy | Fade only |
| Tooltip | Fade in + 2px shift | Disappears immediately | Fade | Fade in only |
| Dialog | Scrim fades in 200ms; panel 0.96 → 1 + rises 8px | 150ms fade + 0.98 | Gentle | Fade only |
| Drawer | Slides in from the edge, 100% → 0 | 200ms exit curve | Smooth | Fade only |
| Bottom sheet (compact) | Rises from the bottom; can be dragged down to close | Follows the finger, or a 200ms exit | Smooth | Fade only |
| Toast | From 16px below + 0.9 → 1 | 150ms fade + down 8px | Bouncy | Fade only |
| Banner | Height expands + fade in | Height collapses + fade out | Smooth | Fade only |
| Accordion | grid-template-rows: 0fr → 1fr; chevron rotates 180° | Reverse | Smooth / chevron snappy | Opens instantly |
| List item added | Row height expands + brand-tint fill fades over 1s ("lit up") | — | Smooth | No height animation; keep the tint fade |
| List item removed | — | Height collapses + fades out, with an undo toast | Smooth | Removed instantly |
| Reorder / make room | Other items FLIP to their new positions | — | Smooth | Jump into place |
| Skeleton → content | 200ms cross-fade (replaced in place, no rise) | — | Fade | Same |
| Numbers | First time, 0 → target over 600ms | On refresh, only a brand-tint flash | Decelerating curve | Show the final value |
| Charts | Lines draw over 600ms, areas fade in after | Morph over 400ms when the range changes | Smooth | Shown immediately |
| Sidebar collapse | Width 256 ↔ 72; labels fade out in 100ms | Reverse | Smooth | Switches instantly |
| Theme switch | Whole-page cross-fade, 200ms (View Transitions where supported) | — | Fade | Switches instantly |
| Bulk bar / save bar | Rises from the bottom + fades in; count changes bounce | Sinks + 150ms fade | Smooth / bouncy | Fade only |
| Success confirmation | The check draws + one dispersion sweep | — | Bouncy / fade | Check only |
Reduced motion
With the system setting prefers-reduced-motion: reduce, or "Reduce motion" in the page or account settings (data-motion="reduce"):
- Spring duration tokens become 1ms (positions arrive instantly).
- Keep 120–150ms opacity fades so changes are still noticeable.
- Turn off the dispersion stagger (
--stagger: 0), sweep loops (skeletons become static), specular light, number counting (show final values) and the success sweep. - Keep pointer tracking while dragging: it's direct manipulation, not animation.
Performance
- Animate only
transform,opacity,clip-pathandfilter; change shadows by fading a pseudo-element; animate height withgrid-template-rows. - At most 50 elements moving at once; in long lists, animate only rows in view.
- Set
will-changeonly while moving, and remove it afterwards. - Specular light only on
hover: hoverdevices, with passive pointer listeners and at most one update per frame.
Interaction
State model, hit targets, response timing, feedback levels, destructive actions, hide versus disable, forms, keyboard, tooltips, bulk selection, drag and drop, copy, navigation and touch gestures.
Components & patterns
Structure, size, states and signature motion for every component; the console dashboard pattern and chart rules; the accessibility checklist.