motion

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Motion

Motion

This skill holds how things move on every screen. It covers what animates, the curve and duration, gestures, exits and reduced motion.
本 skill 规定了每个屏幕上元素的运动方式。它涵盖哪些内容需要动画、动画曲线与时长、手势、退出动画和 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.
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
.
只要项目中存在相应的 motion token,就应直接使用。若不存在,则严格使用这里给出的时长、曲线和缩放值。用户主动触发且运行超过 300ms 的动效,无论采用何种风格都属于一个问题。
是否应该存在动画属于
restraint
。拆解现有动画属于
deconstruct
。是否保持帧率属于
frames
。界面表面、加载状态、hover 样式和声音属于
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
.
页面切换、面板和加载状态通过
opacity
淡入淡出,不做位移或缩放。这就是
motion-opacity-for-chrome
。只对发生变化的那个轴做动画。仅高度变化却同时动画
width
,属于
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.
切换页面时不会重播入场动画。在路由级别的
AnimatePresence
上设置
initial={false}
,任何包裹状态切换的
AnimatePresence
也应如此设置。这就是
motion-no-replay-on-entry
。缓存视图应立即呈现,不出现淡入或 skeleton 闪烁。这就是
motion-no-flash-on-return
。参见 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
.
transition 应列出它所动画的属性,通常是
opacity
和
transform
。使用
transition: all
属于
motion-name-the-properties
。

Toggled state transitions, keyframes run once

状态切换使用 transition,keyframe 只运行一次

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.
交互式状态变化使用 transition 或 spring,当用户反向操作时,它们会重新设定目标。在切换 class 上使用 keyframe 动画属于
motion-transitions-for-state
。keyframe 仅用于只运行一次的序列。

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.
dropdown 或 popover 从其触发元素的位置缩放展开,并将
transform-origin
设置到对应边缘。菜单从自身中心放大,属于
motion-origin-from-trigger
。modal 保持居中,因为没有触发元素为其提供锚点。

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
.
入场动画从
scale(0.95)
或更高的值开始,并同时设置
opacity: 0
。使用
scale(0)
属于
motion-no-scale-from-zero
。

Gestures use springs

手势使用 spring

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.
拖拽、滑动,以及任何用户能在动作进行到一半时反向操作的动效,都应使用 spring。释放时,将 pointer velocity 传入 spring。在释放时固定使用
ease-out
的拖拽属于
motion-spring-for-gestures
。参见 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.
滑动关闭可由距离或速度触发,因此当速度超过约 0.11 px/ms 时,即使只是短促的轻扫也足以关闭。忽略快速轻扫的 sheet 属于
motion-flick-dismisses
。
拖拽越过边界时,阻力应逐渐增大,而不是撞上一堵墙。drawer 在顶部戛然而止属于
motion-friction-at-edges
。拖拽开始后应捕获 pointer,并忽略额外的触摸。参见 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
.
每个拖拽、滑动或 pinch 都应同时支持点击和键盘操作。只能通过拖拽来重排序的列表属于
motion-gesture-alternative
。

Easing fits the change

easing 与变化类型匹配

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
.
toast 等系统级变化使用 easing,而不是 spring。入场和退出动效采用 ease-out,视图切换则采用 ease-in 和 ease-out。linear 仅用于进度条和时钟。以 ease-in 进入的 modal 属于
motion-ease-by-direction
。
内置的
ease-out
关键字缓动力度不足。进入和退出使用
cubic-bezier(0.23, 1, 0.32, 1)
,屏幕上的移动使用
cubic-bezier(0.77, 0, 0.175, 1)
。直接使用裸
ease-out
属于
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
.
关闭动画的时长应短于打开动画。关闭或 hover-out 不得延迟。与打开耗时相同的 modal 属于
motion-close-faster
。

Motion stays under 300ms

动效保持在 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.
hover 时的颜色变化应立即生效,不加 transition。按钮在 hover 时用 200ms 淡变背景色,属于
motion-instant-hover-color
。
按下和 hover 位移的时长为 120–180ms。小幅状态变化的时长为 180–260ms。除完整 panel 或 drawer 最长可达 400ms 外,用户主动触发的任何动效都不得超过 300ms。耗时 500ms 的 dropdown 属于
motion-under-300ms
。
在
mode="wait"
下,退出和入场依次播放,因此两者各自的时长都应减半。感觉动效缓慢时,应先缩短时长,再调整曲线。示例见 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
.
输入、键盘快捷键、键盘导航和快速切换都不做动画。通过键盘打开的 command palette 应立即出现。context menu 也应立即出现,关闭时可以做淡出。每次按键都出现 scale pulse,属于
motion-no-motion-on-repeat
。

Tooltips wait once

tooltip 只等待一次

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
.
一组 tooltip 中,第一个 tooltip 在延迟后打开。当其中一个正在显示时,其相邻 tooltip 应立即打开且不做动画;每个 tooltip 关闭时都应立即消失。工具栏中每个 tooltip 都等待完整延迟,属于
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.
原地切换的图标(例如从 copy 切换为 copied)应进行交叉淡入淡出。scale 从
0.25
变为
1
,opacity 从
0
变为
1
,blur 从
4px
变为
0
。硬切换属于
motion-icon-crossfade
。参见 recipes.md。

Blur bridges a cross-fade

用 blur 连接交叉淡入淡出

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
.
当两个状态之间的交叉淡入淡出仍看起来像两个物体时,在动画过程中添加
filter: blur(2px)
。blur 应保持在 20px 以下,因为在 Safari 中代价较高。切换过程中两个标签明显重叠,属于
motion-blur-the-crossfade
。

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.
控件按下时缩放到
0.98
,挤压或拉伸应保持在
0.95–1.05
范围内。使用
whileTap={{ scale: 0.8 }}
属于
motion-press-scale
。列表项之间的 stagger 间隔应小于 50ms。列表使用
staggerChildren: 0.15
属于
motion-short-stagger
。只播放一次的分阶段入场动画(例如标题、文本和操作按钮)可以按 100ms 间隔 stagger。日常交互绝不使用 stagger,stagger 也绝不能阻塞输入。

Exits need a presence boundary

退出动画需要 presence 边界

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
.
带
exit
的条件渲染元素应放在
AnimatePresence
内,使用稳定的 id 作为 key,并退出到其
initial
值。由于缺少包装元素而导致退出动画从未播放,属于
motion-exit-in-presence
。参见 recipes.md。
组件库中的 dialog 应保持挂载,只切换其
open
prop。通过
open &&
渲染的 dialog 会失去关闭动画,属于
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
.
要动画 container 的
width
或
height
时,使用
ResizeObserver
测量内部元素,再动画外层元素。在同一节点上测量并进行动画,属于
motion-measure-inner
。参见 recipes.md。
panel 折叠时,其内容应保持展开时的宽度,避免动画过程中发生重排。sidebar 关闭时将内容设置为
w-full
,属于
motion-hold-content-size
。

Reduced motion removes movement

reduced motion 移除位移

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
.
当
prefers-reduced-motion: reduce
匹配时,位移、缩放和模糊都改为 opacity 交叉淡入淡出。忽略该设置、仍使用滑动的 drawer 属于
motion-reduced-motion
。

The pair

成对检查

Every check is a pair. The broken line sits beside the line that replaces it.
RuleBrokenReplacement
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 viewThe 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 distanceDismiss past the distance or above
0.11
px per ms
motion-friction-at-edges
A drawer that stops dead at its topMovement 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 delayThe first waits, its neighbours open at once
motion-icon-crossfade
{copied ? <CheckIcon /> : <CopyIcon />}
Both icons cross-fading with scale and blur
motion-blur-the-crossfade
Two button labels overlapping mid-swap
filter: blur(2px)
while the swap runs
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 motionAn opacity cross-fade
每项检查都由一对规则组成:问题写法旁边给出替代方案。
规则问题写法替换方案
motion-opacity-for-chrome
页面上使用
initial={{ x: 20, scale: 0.95 }}
initial={{ opacity: 0 }}
motion-changing-axis-only
仅高度变化时使用
animate={{ width, height }}
animate={{ height }}
motion-no-replay-on-entry
路由外层使用
<AnimatePresence>
,导致入场动画重播
<AnimatePresence initial={false}>
motion-no-flash-on-return
缓存视图出现 skeleton 闪烁缓存视图直接呈现
motion-name-the-properties
transition: all 200ms
transition: opacity 200ms, transform 200ms
motion-transitions-for-state
.open { animation: slide-in 200ms }
在切换属性上使用
transition: transform 200ms
motion-origin-from-trigger
从
center
缩放的 dropdown
左对齐触发器下方使用
transform-origin: top left
motion-no-scale-from-zero
popover 使用
initial={{ scale: 0 }}
initial={{ scale: 0.95, opacity: 0 }}
motion-spring-for-gestures
拖拽释放使用
duration: 0.3, ease: "easeOut"
使用
type: "spring"
,并传入
velocity: info.velocity.x
motion-flick-dismisses
只有超过固定距离才关闭超过距离或速度高于
0.11
px/ms 时关闭
motion-friction-at-edges
drawer 在顶部戛然而止拖拽距离越远,阻力越大
motion-gesture-alternative
只能通过拖拽重排序的行行菜单中提供
Move Up
和
Move Down
motion-ease-by-direction
modal 使用
ease-in
进入
进入和退出均使用 ease-out,退出时长更短
motion-strong-ease-out
transition: transform 200ms ease-out
transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1)
motion-close-faster
modal 进入和退出均为
200ms
进入
200ms
,退出
150ms
motion-instant-hover-color
按钮使用
transition: background-color 200ms
立即应用 hover 颜色
motion-under-300ms
dropdown 使用
transition={{ duration: 0.5 }}
duration: 0.2
motion-no-motion-on-repeat
每次按键都执行
animate={{ scale: [1, 1.02, 1] }}
输入框不做动效
motion-tooltip-delay
工具栏中每个 tooltip 都等待完整延迟第一个 tooltip 等待,其相邻 tooltip 立即打开
motion-icon-crossfade
{copied ? <CheckIcon /> : <CopyIcon />}
两个图标通过 scale 和 blur 交叉淡入淡出
motion-blur-the-crossfade
切换过程中两个按钮标签发生重叠切换过程中使用
filter: blur(2px)
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 }} />}
同一元素放入
<AnimatePresence>
内
motion-dialogs-stay-mounted
{open && <Dialog.Root open>}
<Dialog.Root open={open} onOpenChange={setOpen}>
motion-measure-inner
同一节点上使用
ref
和
animate={{ height: bounds.height }}
外层节点执行动画,内部节点保存
ref
motion-hold-content-size
折叠中的 sidebar 内使用
w-full
内容
使用
w-64
内容并保持其宽度
motion-reduced-motion
drawer 在 reduced motion 下仍使用滑动使用 opacity 交叉淡入淡出

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.
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.
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.
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 mark every check you could not run as
Not verified
.
严重程度。
HIGH
表示问题会破坏功能的可用性或可访问性。例如退出动画从未播放、container 对自身进行测量,或手势没有按钮替代方式。
MEDIUM
表示滑动页面、入场动画重播、使用固定曲线的手势,或动效超过 300ms。输入时出现动效、曲线错误、
transition: all
、在切换状态上使用 keyframe,以及忽略 reduced motion,也属于此级别。条件渲染的 dialog、折叠过程中内容发生重排、以
scale(0)
入场,以及 sheet 忽略轻扫,也属于此级别。
LOW
表示 hover 颜色变化带有淡入淡出、按压缩放过重,或 stagger 间隔过长。动效起点错误、关闭与打开一样慢、tooltip 打开缓慢,以及图标硬切换,也属于此级别。裸用
ease-out
、交叉淡入淡出时未使用 blur,以及 drawer 缺少边缘阻力,也属于此级别。
格式。 每个根本原因只报告一个问题:
file:line - [rule-id] what is wrong
然后给出
Rule | Count | Severity
汇总。只要仍有任何
HIGH
问题,就标记为
Block
;否则标记为
Approve
。绝不能对未读取的文件给出
Approve
。若某项检查依赖时序,应实际播放动效;每项无法运行的检查都应标记为
Not verified
。