Motion
Motion explains a change; it never decorates. The page is complete without it.
Before animating
- 01
How often does it happen?
Repeated actions (typing, toggling, hovering) feel nearly instant or don't animate. Expressive motion is for rare moments.
- 02
What is it for?
Explain space, confirm input, show state or soften a change. If it does none, don't animate.
- 03
Which physics?
--ease-out to enter and exit, --ease-in-out to move what is already on screen, linear only for progress and springs for gestures.
- 04
How does it look with reduced motion?
Design it at the same time: keep opacity, color and state changes; remove travel, scale, parallax and bounce.
Tokens
| Token | Value | Use |
|---|---|---|
--ease-out | cubic-bezier(.22, 1, .36, 1) | Entrances, exits, press and hover: responds at once, then settles. |
--ease-in-out | cubic-bezier(.65, 0, .35, 1) | Something already on screen moving to a new place. |
--ease-linear | linear | Progress only. |
--ease-expressive | cubic-bezier(.32, .72, 0, 1) | Demos, video and large spatial changes. |
--duration-fast | 150ms | Press and hover (100–160ms). |
--duration-pop | 180ms | Tooltips and popovers (125–200ms). |
--duration-menu | 220ms | Dropdowns and selects (150–250ms). |
--duration-panel | 320ms | Modals, drawers and sheets (200–500ms). |
--duration-slow | 700ms | Marketing demos and video only. |
--es-lift | 8px | Travel of an interface entrance. Video uses 24px. |
--es-press | .96 | Scale on press for every control. |
Timing
Under 300ms by default in the interface. Longer motion is for demos, deliberate gestures and large spatial changes.
| Interaction | Token | Range | Feel |
|---|---|---|---|
| Press and hover | --duration-fast | 100–160ms | Immediate and physical |
| Tooltip, popover | --duration-pop | 125–200ms | Quick, born from its origin |
| Dropdown, select | --duration-menu | 150–250ms | Responsive, no waiting |
| Modal, drawer, sheet | --duration-panel | 200–500ms | Just enough to explain space |
| Marketing demo, video | --duration-slow | Flexible | Clarity over speed |
Press
Confirm input before the action finishes: scale to 0.96 in 150ms. It is what Button does.
<button className="transition-[scale,background-color] duration-(--duration-fast) ease-out active:scale-(--es-press) motion-reduce:active:scale-100">
Continue
</button>Entrance
One surface at a time rises 8px and fades in, finishing before it becomes the focus. Lists stagger 30–50ms outward from what you touched.
.reveal {
animation: reveal 700ms var(--ease-out) both;
animation-delay: calc(var(--d, 0) * 100ms); /* staggers 100ms per piece */
}
@keyframes reveal {
from { opacity: 0; translate: 0 var(--es-lift); }
}
/* Reduced motion: the content is simply there. */
@media (prefers-reduced-motion: reduce) {
.reveal { animation: none; }
}Icons with meaning
// The icon acts out its meaning: the arrow turns toward its destination.
// Pointer only, and never with reduced motion.
<ArrowRight className="transition-[rotate] duration-(--duration-fast) ease-out group-hover:-rotate-45 motion-reduce:group-hover:rotate-0" />Continuity and content swaps
- When a surface changes size or place it stays visible: its shape moves first, then its label arrives.
- In a content swap, the old content leaves faster than the new arrives (about 60% of the time) and travels a few pixels at most.
- A menu, popover or sheet grows from the control that opened it (transform-origin) and leaves the way it came.
Gestures
import { motion, useReducedMotion } from "motion/react"
// Gestures use springs: critically damped (no bounce, ~0.4s).
// They animate from the current value, so a gesture can be grabbed and reversed.
const reduce = useReducedMotion()
<motion.div
drag="x"
dragConstraints={{ left: 0, right: 0 }}
transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0, duration: 0.4 }}
/>Reduced motion
A designed state, not an absence: keep opacity, color and instant changes; remove travel, scale, parallax and bounce. No state change lives only in an animation.
const hidden = { opacity: 0, translate: reduce ? "0 0" : "0 var(--es-lift)" }
const visible = { opacity: 1, translate: "0 0" }Never
- Marquees that scroll on their own.
- Typewriter effects.
- Pulsing status dots.
- Parallax.
- Revealing every section on scroll.
- Bounce on things nobody threw.
- Animating anything but transform and opacity.