Getting started
The motion standard
Every component moves the same way or the library has no feel. These are not suggestions — a component that breaks one owes an explanation in its README, and exactly one does.
Why there is a standard
A library is not twenty components, it is one. If a menu opens with one curve and a popover with another, the difference is not read as variety — it is read as one of them being wrong, and nobody can say which. Consistency is what turns a folder of parts into something that feels made.
One easing, and it is an ease-out
cubic-bezier(0.22, 1, 0.36, 1), everywhere.
The first frames are the ones being watched, and anything that eases in spends them standing still — an ease-in at 200ms feels slower than an ease-out at 300ms. Nothing in this package uses one. Linear is for constant motion only: a spinner, a marquee, a strip that has to burn evenly.
const EASE = [0.22, 1, 0.36, 1] as const;
transition={{ duration: 0.2, ease: EASE }}Durations, by what the thing is
All under 300ms. The scale is the object, not the distance it travels.
| What it is | How long |
|---|---|
| Press feedback | 100–160ms |
| Tooltip, small tip | 125–200ms |
| Menu, dropdown, popover | 150–250ms |
| Dialog, drawer | 200–500ms |
And one rule that overrides the table: the faster a thing is invoked, the less it should move. The command palette is opened by a chord forty times a day, so it does not animate at all.
Out is faster than in, and never the entrance reversed
A panel that leaves as slowly as it arrived holds the page hostage twice. Exits are tweens even when entrances are springs — nothing needs to settle on its way out, and a spring on an exit is a thing wobbling as it leaves.
Springs are for what the pointer drives
A sliding bar, a dragged card, a knob following a finger — anything interruptible, because a spring keeps its velocity where a tween restarts from zero. Panels that merely open are tweens.
Springs live in the 320–600 stiffness / 26–45 damping range, and what matters is the ratio: critical damping is 2 × √stiffness. At 520 that is about 46, so a damping of 34 is deliberately under — the overshoot is what makes a thumb arrive rather than slide in.
One exception, and it is a finger
Nothing animates towards a pointer that is currently down. A thumb that eases towards where your finger is, is a thumb that is behind it, and the lag is the only thing you feel.
if (dragging.current) return; // a finger is not something to animate towardsEntrances and origins
Nothing appears from scale(0). Nothing in the world does. Entrances start at 0.94–0.98 with opacity, which is the difference between something arriving and something being switched on.
Anchored things grow from their anchor. transform-origin at the trigger's corner for popovers, menus and tips. Dialogs are the exception and stay centred: they are not anchored to anything.
Press feedback, and why it is CSS
active:scale-[0.97] — 0.98 for a full-width row — on a 100–150ms CSS transition. In CSS rather than in Motion on purpose: this one must not wait behind whatever else has the main thread, and it is the movement a dropped frame ruins.
Never
| Never | Because |
|---|---|
| transition-all | It animates properties you did not mean, including ones that force layout. |
| transition-[colors, …] | Not valid CSS. `colors` is a Tailwind name, not a property — spell them out: transition-[background-color,box-shadow,color,transform]. |
| setState per frame | Per `pointermove` or per animation frame. Use a motion value, or write to the node. |
| Two surfaces for one claim | One active row, one bar, one highlight. Hover and keyboard focus are the same question. |
Reduced motion is gentler, not absent
Position and scale go; opacity and colour stay, because they carry meaning. A control that stops answering the pointer reads as broken, so hover keeps a short tween rather than nothing. The one place movement really is removed is smooth scrolling — a page that slides two thousand pixels is the thing that makes people ill.
const reduced = Boolean(useReducedMotion());
transition={{ duration: reduced ? 0 : 0.2, ease: EASE }}