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
, 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
. Taking an existing animation apart belongs to
. Whether it holds its frame rate belongs to
. Surfaces, loading, hover styles and sound belong to
.
App chrome fades
Page switches, panels and loading states fade with
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
on the route-level
, 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
and
.
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
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
or higher, together with
.
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
is
motion-spring-for-gestures
. See
recipes.md.
Flicks dismiss, edges resist
A swipe dismisses on distance or on speed, so past about
px per millisecond a short flick is enough. A sheet that ignores a fast flick is
.
Dragging past a boundary meets growing friction rather than a wall. A drawer that stops dead at the top is
. 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
is
.
The built-in
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
is
.
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 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
.
Under
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
.
Icons cross-fade when they swap
An icon that swaps in place, such as copy to copied, cross-fades. Scale runs
to
, opacity
to
and blur
to
. A hard swap is
. See
recipes.md.
Two icons in the morph form from
morph instead, animating each path's
as a small state change on the movement curve. Under reduced motion they cross-fade. A cross-fade between two morph-form icons is
. See
recipes.md.
Blur bridges a cross-fade
When a cross-fade between two states still reads as two objects, add
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
in React and
elsewhere. The package sizes it from speed, frame time and shutter, follows curves and clears it at rest. A hand-written
or copy loop on a moving element is
. 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
. With the
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
. Put it on an element inside.
Echo draws the streak
Keep the default
. It is the only technique shaped like a camera's blur, and the only one that follows rotation, scale and curves. Reach for
,
or
only when a
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
.
and
turn the layer and its child in opposite directions, so the layer wraps exactly one element. A layer with two children throws, and is
.
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
. See
recipes.md.
A demo played at a quarter of its speed multiplies the shutter by four, so
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
removes every style
set, and whose
removes anything it added to the page. A technique built inside a render also sets
, so an equal one does not rebuild the painter. A filter left on a still element, or an inline technique with no
, is
motion-blur-custom-technique
. See
recipes.md.
Presses scale lightly
A pressed control scales to
, and squash or stretch stays within
.
whileTap={{ scale: 0.8 }}
is
. List items stagger by less than 50ms each.
on a list is
. 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
sits inside
, keyed by a stable id, and exits to its
values. An exit that never plays because the wrapper is missing is
. See
recipes.md.
A dialog from a component library stays mounted and toggles its
prop. A dialog rendered behind
, 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
and animate the outer one. Measuring and animating the same node is
. See
recipes.md.
While a panel collapses, its content keeps its open width, so nothing reflows mid-animation. Sidebar content set to
as the sidebar closes is
.
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 blur drops its streak under the setting while
stays at
.
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 | |
motion-changing-axis-only
| animate={{ width, height }}
when only height changes | |
motion-no-replay-on-entry
| 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: 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 | transform-origin: top left
under a left-aligned trigger |
motion-no-scale-from-zero
| on a popover | initial={{ scale: 0.95, opacity: 0 }}
|
motion-spring-for-gestures
| A drag release on duration: 0.3, ease: "easeOut"
| with velocity: info.velocity.x
|
| Dismiss only past a fixed distance | Dismiss past the distance or above px per ms |
| A drawer that stops dead at its top | Movement damped the further it is dragged |
motion-gesture-alternative
| Rows that reorder only by drag | and in the row menu |
| A modal entering on | Ease-out in and out, the exit shorter |
| transition: transform 200ms ease-out
| transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1)
|
| A modal at in and out | in, out |
motion-instant-hover-color
| transition: background-color 200ms
on a button | The hover colour applied at once |
| transition={{ duration: 0.5 }}
on a dropdown | |
motion-no-motion-on-repeat
| animate={{ scale: [1, 1.02, 1] }}
on each keystroke | No motion on the input |
| Each toolbar tooltip waiting the full delay | The first waits, its neighbours open at once |
| {copied ? <CheckIcon /> : <CopyIcon />}
| Both icons cross-fading with scale and blur |
| Two morph-form icons cross-fading | Each animating to its partner |
motion-blur-the-crossfade
| Two button labels overlapping mid-swap | while the swap runs |
motion-blur-untracked-only
| Blur on a carousel page swipe | No blur, or blur only on the flung card |
| while a card flies | on the card |
| on a wrapper around the | on the |
motion-blur-transparent-host
| on the host | The same classes on its first child |
| on the layer | on an element inside the layer |
motion-blur-echo-for-turns
| on a spinner | |
| on a card with text | at its 1.05 default, or |
| on a layer holding an icon and a label | The icon and label wrapped in one element |
| blur.render(previous, current)
on an orbit | blur.render((t) => at(angle + t * speed * frameTime))
|
motion-blur-slowdown-shutter
| A ¼× demo at | |
motion-blur-custom-technique
| , or technique: ({ layer }) => …
inside a render | removing every style set, and a |
| whileTap={{ scale: 0.8 }}
| whileTap={{ scale: 0.98 }}
|
| | |
| {open && <motion.div exit={{ opacity: 0 }} />}
| The same element inside |
motion-dialogs-stay-mounted
| {open && <Dialog.Root open>}
| <Dialog.Root open={open} onOpenChange={setOpen}>
|
| and animate={{ height: bounds.height }}
on one node | The outer node animates, the inner node holds the |
| content inside a collapsing sidebar | content that keeps its width |
| A drawer that slides under reduced motion | An opacity cross-fade |
motion-blur-reduced-motion
| | The default |
Reporting
Severity. 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
or
.
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,
, keyframes on toggled state or ignored reduced motion. It is also a conditionally rendered dialog, content reflowing as it collapses, a
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
.
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
, 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
summary.
when any
remains,
otherwise. Never
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
.