Foundations
Motion
Motion here is functional. It explains where a surface came from, confirms that a press registered, and connects one state to the next — and then it gets out of the way.
PrinciplesPermalink to this section
Six rules. They rule out most of what makes interfaces feel busy.
Animate transform and opacity
Both are composited on the GPU. Animating width, height, top or margin forces layout on every frame and drops the animation to the main thread.
Movement is small
Entrances travel 8–12px and scale from 97%. A surface that flies in from off-screen takes attention that belongs to the content.
Exits are faster than entrances
Arriving deserves a moment; leaving does not. Entrances use 240ms and an ease-out curve, exits 150ms and an ease-in.
Nothing loops
The only repeating animation in the system is a loading spinner, and it stops when the load does. Decorative perpetual motion is never justified.
Stagger is rare
Sequencing helps someone parse a group they have not seen before. Applied to a list they scroll past every day, it is just delay.
Reduced motion removes movement
Not shortens it. A 20ms slide is still a slide, and it can still trigger vestibular symptoms.
DurationsPermalink to this section
Five steps. If an interaction needs a duration that is not on this list, the interaction has not been thought through.
instant90msfast150msnormal240msslow360msreveal520ms| Interaction | Range | Token | Why |
|---|---|---|---|
| Hover | 120–180ms | duration-fast | Fast enough to feel attached to the cursor. |
| Pressed feedback | 80–120ms | duration-instant | Confirms the press without delaying the result. |
| Dropdown and tooltip | 180–260ms | duration-normal | Long enough to trace where the surface came from. |
| Dialog | 200–280ms | duration-normal | Paired with an overlay fade so focus movement reads clearly. |
| Section reveal | 360–520ms | duration-slow / duration-reveal | Only on first view, never on repeat scroll. |
EasingPermalink to this section
Asymmetric curves. Things decelerate as they arrive and accelerate as they leave, which is what makes movement read as physical rather than mechanical.
| Token | Curve | Use for |
|---|---|---|
--ease-standard | cubic-bezier(0.22, 0, 0.12, 1) | Default for hover, colour and border changes |
--ease-enter | cubic-bezier(0.08, 0.72, 0.16, 1) | Elements arriving on screen |
--ease-exit | cubic-bezier(0.3, 0, 0.8, 0.15) | Elements leaving the screen |
--ease-emphasized | cubic-bezier(0.2, 0, 0, 1) | Larger, more deliberate movements |
--ease-spring | cubic-bezier(0.34, 1.3, 0.64, 1) | Slight overshoot — the switch thumb only |
EntrancesPermalink to this section
Three ways something can arrive. Every one of them includes an opacity change, so the animation still reads when transforms are removed.
Fade
Opacity only. The safest entrance — it survives reduced motion unchanged.
Slide
Translate plus fade. The movement is short — 12px, not 60.
Scale
From 97%, never from zero. A surface should feel like it arrived, not inflated.
Sequencing and layoutPermalink to this section
Stagger, expansion and shared layout — the three cases where motion carries information rather than polish.
Stagger
60ms apart, and only where the sequence helps someone parse a new group.
Expand and collapse
Height is the one place the system animates layout — there is no transform equivalent.
Overlay enter and exit
Entrances are gentle and slower; exits are decisive and faster.
Shared layout transition
One indicator moves between positions instead of two fading past each other.
Height is the one exception
Expand and collapse animates height, which does force layout. There is no transform equivalent, and the alternative — an instant jump — is genuinely worse for comprehension. It is the only place the system accepts that cost.
Reduced motionPermalink to this section
Around one in three people report some sensitivity to motion. The setting is not a preference for less polish; it is an accessibility requirement.
prefers-reduced-motion: no-preferenceReduced-motion fallback
You have reduced motion off — the block fades and slides.
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
/* Colour and opacity transitions are kept, so state
changes stay perceivable rather than snapping. */
transition-property: opacity, background-color, border-color, color !important;
transition-duration: 0.01ms !important;
}
/* Movement lives in transforms — remove it rather than
speed it up. A 20ms slide is still a slide. */
[data-motion] {
transform: none !important;
}
}Shortening is not reducing
The common mistake is to divide every duration by ten. The movement is still there, just faster — which for someone with vestibular sensitivity can be worse. Remove the transform, keep the fade.
TokensPermalink to this section
Motion tokens exist twice: as CSS custom properties for transitions, and as TypeScript constants for JavaScript-driven animation. Both come from the same numbers.
// Seconds, for Motion for React
export const duration = {
instant: 0.09,
fast: 0.15,
normal: 0.24,
slow: 0.36,
reveal: 0.52,
} as const;
export const easing = {
standard: [0.22, 0, 0.12, 1],
enter: [0.08, 0.72, 0.16, 1],
exit: [0.3, 0, 0.8, 0.15],
emphasized: [0.2, 0, 0, 1],
spring: [0.34, 1.3, 0.64, 1],
} as const;