Motion
This skill holds how things move on every screen. It covers what animates, the curve and duration, gestures, exits, motion blur and reduced motion.
Durations and curves are exact
Use the project's motion tokens wherever they exist. Where none exists, the durations, curves and scales here are exact. Motion the user starts that runs past 300ms is a finding in any style.
Motion blur draws with @web-kits/motion-blur, whose defaults are exact: a 180° shutter, Echo at one copy per 1.5px up to 16 copies and a 1.05 smear limit.
Whether an animation should exist at all belongs to restraint. Taking an existing animation apart belongs to deconstruct. Whether it holds its frame rate belongs to frames. Surfaces, loading, hover styles and sound belong to defaults.
App chrome fades
Page switches, panels and loading states fade with opacity and do not slide or scale. That is motion-opacity-for-chrome. Animate only the axis that changes. Animating width when only height changes is motion-changing-axis-only.
Entrances do not replay
Switching pages does not replay entrances. Set initial={false} on the route-level AnimatePresence, and on any that wraps a state swap. That is motion-no-replay-on-entry. A cached view renders at once, with no fade or skeleton flash. That is motion-no-flash-on-return. See recipes.md.
Name the properties you animate
A transition lists the properties it animates, usually opacity and transform. transition: all is motion-name-the-properties.
Toggled state transitions, keyframes run once
Interactive state changes use transitions or springs, which retarget when the user reverses. A keyframe animation on a toggled class is motion-transitions-for-state. Keep keyframes for sequences that run once.
Motion starts where it comes from
A dropdown or popover scales from its trigger, with transform-origin set to that edge. A menu growing from its own centre is motion-origin-from-trigger. Modals stay centred, because no trigger anchors them.
Nothing grows from zero
An entrance starts from scale(0.95) or higher, together with opacity: 0. scale(0) is motion-no-scale-from-zero.
Gestures use springs
Drag, swipe and any motion the user can reverse mid-flight use a spring. On release, pass the pointer velocity into it. A drag release on a fixed ease-out is motion-spring-for-gestures. See recipes.md.
Flicks dismiss, edges resist
A swipe dismisses on distance or on speed, so past about 0.11 px per millisecond a short flick is enough. A sheet that ignores a fast flick is motion-flick-dismisses.
Dragging past a boundary meets growing friction rather than a wall. A drawer that stops dead at the top is motion-friction-at-edges. Capture the pointer once a drag starts and ignore extra touches. See recipes.md.
Every gesture has a button
Each drag, swipe or pinch also works with a click and with the keyboard. A list that reorders only by dragging is motion-gesture-alternative.
Easing fits the change
System changes such as a toast use easing, not a spring. Entrances and exits ease out, and a change of view eases in and out. Linear is for progress bars and clocks only. A modal entering on ease-in is motion-ease-by-direction.
The built-in ease-out keyword is too weak. Use cubic-bezier(0.23, 1, 0.32, 1) to enter and exit, and cubic-bezier(0.77, 0, 0.175, 1) for movement on screen. A bare ease-out is motion-strong-ease-out.
Closes are faster than opens
A close runs shorter than its open. Nothing delays a close or a hover-out. A modal that takes as long to leave as it took to arrive is motion-close-faster.
Motion stays under 300ms
Hover colour changes are instant, with no transition. A button fading its background over 200ms on hover is motion-instant-hover-color.
Press and hover movement take 120–180ms. Small state changes take 180–260ms. Nothing the user starts runs longer than 300ms, except a full panel or drawer at up to 400ms. A 500ms dropdown is motion-under-300ms.
Under mode="wait" the exit and the entrance play in series, so halve each. When motion feels slow, shorten it before changing the curve. Samples are in recipes.md.
Frequent actions do not animate
Typing, keyboard shortcuts, keyboard navigation and fast toggles do not animate. A command palette opened from the keyboard appears at once. A context menu appears at once and may fade out. A scale pulse on every keystroke is motion-no-motion-on-repeat.
Tooltips wait once
The first tooltip in a group opens after a delay. Its neighbours open at once and without animation while one is showing, and every tooltip closes instantly. A toolbar where each tooltip waits the full delay is motion-tooltip-delay.
Icons cross-fade when they swap
An icon that swaps in place, such as copy to copied, cross-fades. Scale runs 0.25 to 1, opacity 0 to 1 and blur 4px to 0. A hard swap is motion-icon-crossfade. See recipes.md.
Two icons in the morph form from icons morph instead, animating each path's d as a small state change on the movement curve. Under reduced motion they cross-fade. A cross-fade between two morph-form icons is motion-icon-morph. See recipes.md.
Blur bridges a cross-fade
When a cross-fade between two states still reads as two objects, add filter: blur(2px) while it runs. Keep blur under 20px, which gets expensive in Safari. Two labels visibly overlapping mid-swap is motion-blur-the-crossfade.
Motion blur only where the eye cannot follow
A screen already smears what the eye tracks, so added blur turns text into grey bars while someone reads it. Blur a thrown card, a dismissed sheet, a panel snapping in or a spinner. Blur on scrolling, a carousel page, a list sliding into place or text being read is motion-blur-untracked-only.
Draw the streak with useMotionBlur in React and motionBlur(element) elsewhere. The package sizes it from speed, frame time and shutter, follows curves and clears it at rest. A hand-written filter: blur() or copy loop on a moving element is motion-blur-package. See recipes.md.
The blur sits on what moves
The package watches the host's own transform. A ref on a wrapper while its child animates draws nothing, and is motion-blur-ref-on-mover. With the MotionBlur component, animate the component itself and style what sits inside it.
The host's first child is the layer, and the streak is drawn into it. Backgrounds, borders and shadows go on the layer or inside it. A background on the host stays sharp under the streak and is motion-blur-transparent-host.
The techniques write the layer's transform, so the layer has none of its own. A tilt or scale on the layer is lost while it moves, which is motion-blur-bare-layer. Put it on an element inside.
Echo draws the streak
Keep the default echo(). It is the only technique shaped like a camera's blur, and the only one that follows rotation, scale and curves. Reach for squeeze(), svg() or smear() only when a frames trace shows the copies cost too much.
Those three blur the whole layer one amount in one direction, but a spinning element's edges move at different speeds. A spinner or orbit on anything but Echo is motion-blur-echo-for-turns. A smear limit past 1.05 on anything with text is motion-blur-smear-limit.
squeeze() and svg() turn the layer and its child in opposite directions, so the layer wraps exactly one element. A layer with two children throws, and is motion-blur-one-child.
Curves pass a path
For a position set by hand, such as a physics loop, call blur.render(previous, current) once a frame. When the motion bends, pass a path instead so the copies sit along the curve. Two transforms on an orbit is motion-blur-follow-path. See recipes.md.
A demo played at a quarter of its speed multiplies the shutter by four, so shutter: 720 keeps the full-speed streak. A slow-motion control left at 180° is motion-blur-slowdown-shutter.
Custom techniques undo everything
A custom technique returns a painter whose clear removes every style paint set, and whose destroy removes anything it added to the page. A technique built inside a render also sets key, so an equal one does not rebuild the painter. A filter left on a still element, or an inline technique with no key, is motion-blur-custom-technique. See recipes.md.
Presses scale lightly
A pressed control scales to 0.98, and squash or stretch stays within 0.95–1.05. whileTap={{ scale: 0.8 }} is motion-press-scale. List items stagger by less than 50ms each. staggerChildren: 0.15 on a list is motion-short-stagger. A staged entrance that plays once, such as a title, text and actions, staggers by 100ms. Routine interactions never stagger, and a stagger never blocks input.
Exits need a presence boundary
A conditional element with an exit sits inside AnimatePresence, keyed by a stable id, and exits to its initial values. An exit that never plays because the wrapper is missing is motion-exit-in-presence. See recipes.md.
A dialog from a component library stays mounted and toggles its open prop. A dialog rendered behind open &&, which loses its close animation, is motion-dialogs-stay-mounted.
Size follows content from a second element
To animate a container's width or height, measure an inner element with a ResizeObserver and animate the outer one. Measuring and animating the same node is motion-measure-inner. See recipes.md.
While a panel collapses, its content keeps its open width, so nothing reflows mid-animation. Sidebar content set to w-full as the sidebar closes is motion-hold-content-size.
Reduced motion removes movement
When prefers-reduced-motion: reduce matches, slides, scales and blurs become an opacity cross-fade. A sliding drawer that ignores the setting is motion-reduced-motion.
Motion blur drops its streak under the setting while reducedMotion stays at "user". reducedMotion: "never" is motion-blur-reduced-motion.
The pair
Every check is a pair. The broken line sits beside the line that replaces it.
| Rule | Broken | Replacement |
|---|---|---|
motion-opacity-for-chrome |
initial={{ x: 20, scale: 0.95 }} on a page |
initial={{ opacity: 0 }} |
motion-changing-axis-only |
animate={{ width, height }} when only height changes |
animate={{ height }} |
motion-no-replay-on-entry |
<AnimatePresence> around routes, entrances replay |
<AnimatePresence initial={false}> |
motion-no-flash-on-return |
A skeleton flash on a cached view | The cached view rendered at once |
motion-name-the-properties |
transition: all 200ms |
transition: opacity 200ms, transform 200ms |
motion-transitions-for-state |
.open { animation: slide-in 200ms } |
transition: transform 200ms on the toggled property |
motion-origin-from-trigger |
A dropdown scaling from center |
transform-origin: top left under a left-aligned trigger |
motion-no-scale-from-zero |
initial={{ scale: 0 }} on a popover |
initial={{ scale: 0.95, opacity: 0 }} |
motion-spring-for-gestures |
A drag release on duration: 0.3, ease: "easeOut" |
type: "spring" with velocity: info.velocity.x |
motion-flick-dismisses |
Dismiss only past a fixed distance | Dismiss past the distance or above 0.11 px per ms |
motion-friction-at-edges |
A drawer that stops dead at its top | Movement damped the further it is dragged |
motion-gesture-alternative |
Rows that reorder only by drag | Move Up and Move Down in the row menu |
motion-ease-by-direction |
A modal entering on ease-in |
Ease-out in and out, the exit shorter |
motion-strong-ease-out |
transition: transform 200ms ease-out |
transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1) |
motion-close-faster |
A modal at 200ms in and 200ms out |
200ms in, 150ms out |
motion-instant-hover-color |
transition: background-color 200ms on a button |
The hover colour applied at once |
motion-under-300ms |
transition={{ duration: 0.5 }} on a dropdown |
duration: 0.2 |
motion-no-motion-on-repeat |
animate={{ scale: [1, 1.02, 1] }} on each keystroke |
No motion on the input |
motion-tooltip-delay |
Each toolbar tooltip waiting the full delay | The first waits, its neighbours open at once |
motion-icon-crossfade |
{copied ? <CheckIcon /> : <CopyIcon />} |
Both icons cross-fading with scale and blur |
motion-icon-morph |
Two morph-form icons cross-fading | Each motion.path animating d to its partner |
motion-blur-the-crossfade |
Two button labels overlapping mid-swap | filter: blur(2px) while the swap runs |
motion-blur-untracked-only |
Blur on a carousel page swipe | No blur, or blur only on the flung card |
motion-blur-package |
filter: blur(8px) while a card flies |
useMotionBlur() on the card |
motion-blur-ref-on-mover |
ref={blur.ref} on a wrapper around the motion.div |
ref={blur.ref} on the motion.div |
motion-blur-transparent-host |
bg-white rounded-xl on the host |
The same classes on its first child |
motion-blur-bare-layer |
rotate-3 on the layer |
rotate-3 on an element inside the layer |
motion-blur-echo-for-turns |
technique: squeeze() on a spinner |
technique: echo() |
motion-blur-smear-limit |
smear({ limit: 1.3 }) on a card with text |
smear() at its 1.05 default, or echo() |
motion-blur-one-child |
squeeze() on a layer holding an icon and a label |
The icon and label wrapped in one element |
motion-blur-follow-path |
blur.render(previous, current) on an orbit |
blur.render((t) => at(angle + t * speed * frameTime)) |
motion-blur-slowdown-shutter |
A ¼× demo at shutter: 180 |
shutter: 720 |
motion-blur-custom-technique |
clear() {}, or technique: ({ layer }) => … inside a render |
clear() removing every style paint set, and a key |
motion-press-scale |
whileTap={{ scale: 0.8 }} |
whileTap={{ scale: 0.98 }} |
motion-short-stagger |
staggerChildren: 0.15 |
staggerChildren: 0.03 |
motion-exit-in-presence |
{open && <motion.div exit={{ opacity: 0 }} />} |
The same element inside <AnimatePresence> |
motion-dialogs-stay-mounted |
{open && <Dialog.Root open>} |
<Dialog.Root open={open} onOpenChange={setOpen}> |
motion-measure-inner |
ref and animate={{ height: bounds.height }} on one node |
The outer node animates, the inner node holds the ref |
motion-hold-content-size |
w-full content inside a collapsing sidebar |
w-64 content that keeps its width |
motion-reduced-motion |
A drawer that slides under reduced motion | An opacity cross-fade |
motion-blur-reduced-motion |
reducedMotion: "never" |
The default "user" |
Reporting
Severity. HIGH breaks use or access. That is an exit that never plays, a container that measures itself or a gesture with no button. It is also motion blur on text being read, or a layer with two children under squeeze() or svg().
MEDIUM is a sliding page, a replayed entrance, a gesture on a fixed curve or motion over 300ms. It is also motion on typing, a wrong curve, transition: all, keyframes on toggled state or ignored reduced motion. It is also a conditionally rendered dialog, content reflowing as it collapses, a scale(0) entrance or a sheet that ignores a flick. It is also a hand-written blur, a blur ref on a wrapper, an opaque host or a transform on the layer. It is also a spinner on anything but Echo, a smear past 1.05 or reducedMotion: "never".
LOW is a faded hover colour, a heavy press scale or a long stagger. It is also motion from the wrong origin, a close as slow as its open, a slow tooltip or a hard icon swap. It is also a bare ease-out, an unblurred cross-fade or a drawer with no edge friction. It is also two transforms on a curve, a slowed demo at the full-speed shutter or a custom technique that leaves styles or rebuilds.
Format. One finding per root cause:
file:line - [rule-id] what is wrong
Then a Rule | Count | Severity summary. Block when any HIGH remains, Approve otherwise. Never Approve a file you did not read. Play the motion when a check depends on timing, and freeze it at its fastest point when a check depends on a streak. Mark every check you could not run as Not verified.
