关于 motion
motion 是一项规范界面动画、手势和运动模糊行为的技能,面向实现 UI 动效的开发者。它提供具体的时长、缓动、减少动态效果及实现规则。
主要功能
- 01规定时长与缓动曲线
- 02指导 UI 状态转换
- 03定义手势与关闭行为
- 04使用 @web-kits/motion-blur 实现运动模糊
- 05遵循减少动态效果设置
适用场景
- 01实现页面和面板切换
- 02构建可拖动或滑动关闭的控件
- 03为运动元素添加运动模糊
- 04检查动画对减少动态效果设置的响应
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ raphaelsalaja/ skills --skill motion安装后在 Claude Code 中输入/motion确认已加载。
附带文件agents/openai.yamlrecipes.md
详细介绍
动效
此技能涵盖所有屏幕上的动效,包括哪些内容会动、曲线和时长、手势、退出、运动模糊和减少动态效果。
时长和曲线均为精确值
只要项目中存在动效令牌,就使用它们。若没有,则此处的时长、曲线和缩放值均为精确值。用户触发且持续时间超过 300ms 的动效,在任何样式下都属于问题。
运动模糊使用 @web-kits/motion-blur 绘制,其默认值均为精确值:180° 快门,Echo 每 1.5px 生成一份副本,最多 16 份,smear 上限为 1.05。
是否应该存在某个动画,属于 restraint 的范畴。拆解现有动画属于 deconstruct。动画是否能保持帧率,属于 frames。表面、加载、悬停样式和声音属于 defaults。
应用界面元素淡入淡出
页面切换、面板和加载状态使用 opacity 淡入淡出,不滑动或缩放。这属于 motion-opacity-for-chrome。只对发生变化的轴进行动画处理。只改变高度时却对宽度进行动画处理,属于 motion-changing-axis-only。
进入动画不会重播
切换页面时不会重播进入动画。在路由级的 AnimatePresence 以及任何包裹状态切换的 AnimatePresence 上设置 initial={false}。这属于 motion-no-replay-on-entry。缓存视图会立即渲染,不会闪现淡入效果或骨架屏。这属于 motion-no-flash-on-return。参见 recipes.md。
明确指定要动画的属性
过渡效果会列出要进行动画处理的属性,通常是 opacity 和 transform。transition: all 属于 motion-name-the-properties。
切换状态使用过渡效果,关键帧只运行一次
交互状态变化使用过渡效果或弹簧,用户反向操作时,动画会重新设定目标。切换类上使用关键帧动画,属于 motion-transitions-for-state。关键帧应保留给只运行一次的序列。
动效从其来源开始
下拉菜单或弹出框从触发器处缩放,并将 transform-origin 设为对应边缘。从自身中心展开的菜单属于 motion-origin-from-trigger。模态框保持居中,因为没有触发器锚定它们。
不从零开始放大
进入动画从 scale(0.95) 或更高的值开始,同时设置 opacity: 0。scale(0) 属于 motion-no-scale-from-zero。
手势使用弹簧
拖动、滑动以及用户可以在动画进行中反向操作的任何动效,都使用弹簧。松开时,将指针速度传入弹簧。固定使用 ease-out 的拖动松手动画属于 motion-spring-for-gestures。参见 recipes.md。
快速轻扫触发关闭,边缘提供阻力
达到指定距离或速度时,滑动会关闭,因此速度超过约 0.11 px/ms 时,轻轻一甩也足以触发关闭。忽略快速轻扫的面板属于 motion-flick-dismisses。
拖动超出边界时,应感受到逐渐增加的摩擦力,而不是撞上一堵墙。在顶部戛然而止的抽屉属于 motion-friction-at-edges。拖动开始后捕获指针,并忽略额外触摸。参见 recipes.md。
每种手势都有按钮操作
每种拖动、滑动或捏合操作,也都能通过点击和键盘完成。只能通过拖动重新排序的列表属于 motion-gesture-alternative。
缓动曲线应匹配变化类型
Toast 等系统变化使用缓动,而非弹簧。进入和退出使用缓出,视图切换使用缓入和缓出。线性运动仅用于进度条和时钟。使用 ease-in 进入的模态框属于 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。
关闭比打开更快
关闭动画的时长应短于打开动画。关闭和悬停结束都不应有延迟。关闭时长与打开时长相同的模态框属于 motion-close-faster。
动效持续时间不超过 300ms
悬停时的颜色变化立即完成,不添加过渡效果。按钮悬停时用 200ms 淡变背景色,属于 motion-instant-hover-color。
按下和悬停时的位移持续 120–180ms。小型状态变化持续 180–260ms。用户触发的动效不超过 300ms,完整面板或抽屉除外,最长可达 400ms。持续 500ms 的下拉菜单属于 motion-under-300ms。
在 mode="wait" 下,退出和进入动效会依次播放,因此各自的时长应减半。当动效感觉迟缓时,先缩短时长,再调整曲线。示例见 recipes.md。
高频操作不添加动效
输入、键盘快捷键、键盘导航和快速切换都不添加动效。通过键盘打开的命令面板会立即出现。上下文菜单会立即出现,也可以淡出。每次按键都触发缩放脉冲,属于 motion-no-motion-on-repeat。
提示工具只等待一次
一组提示工具中的第一个会延迟打开。在它显示期间,相邻提示工具会立即打开,不添加动效;所有提示工具都会立即关闭。工具栏中每个提示工具都等待完整延迟时间,属于 motion-tooltip-delay。
图标切换时交叉淡变
图标在原位切换时(例如从复制图标切换为已复制图标),使用交叉淡变。缩放值从 0.25 变为 1,不透明度从 0 变为 1,模糊度从 4px 变为 0。直接切换属于 motion-icon-crossfade。参见 recipes.md。
icons 中的两个图标形变形式之间,则通过沿位移动效曲线将各路径的 d 作为小型状态变化来动画处理。减少动态效果时,它们会交叉淡变。两个形变形式图标之间使用交叉淡变,属于 motion-icon-morph。参见 recipes.md。
用模糊衔接交叉淡变
如果两个状态之间的交叉淡变仍像是两个物体,播放期间添加 filter: blur(2px)。模糊度应低于 20px,因为 Safari 中超过此值的开销很大。切换过程中两个标签明显重叠,属于 motion-blur-the-crossfade。
仅在视线无法跟上的地方添加动态模糊
屏幕已经会模糊眼睛追踪的内容,因此额外的模糊会在用户阅读时把文字变成灰色条纹。可对抛出的卡片、消失的工作表、弹入的面板或加载旋转图标添加模糊。对滚动内容、轮播页面、滑入到位的列表或正在阅读的文字添加模糊,属于 motion-blur-untracked-only。
在 React 中使用 useMotionBlur 绘制拖影,其他场景则使用 motionBlur(element)。该包会根据速度、帧时间和快门调整拖影大小,跟随曲线,并在静止时清除拖影。在移动元素上手写 filter: blur() 或复制循环,属于 motion-blur-package。参见 recipes.md。
模糊作用于移动的元素
该包会监测宿主自身的变换。在包装元素上设置 ref,而其子元素执行动画时不会绘制任何内容,这属于 motion-blur-ref-on-mover。使用 MotionBlur 组件时,应对组件本身添加动画,并为其内部元素设置样式。
宿主的第一个子元素是图层,拖影会绘制在其中。背景、边框和阴影应设置在该图层或其内部。设置在宿主上的背景在拖影下仍会保持清晰,这属于 motion-blur-transparent-host。
这些技术会写入图层的变换,因此图层自身不能有变换。图层移动时,其倾斜或缩放会丢失,这属于 motion-blur-bare-layer。应将变换设置在内部元素上。
使用 Echo 绘制拖影
保留默认的 echo()。它是唯一形状类似相机模糊的技术,也是唯一能跟随旋转、缩放和曲线的技术。仅当 frames 追踪显示副本开销过大时,才使用 squeeze()、svg() 或 smear()。
这三种技术会以单一方向和固定程度模糊整个图层,但旋转元素的边缘移动速度各不相同。对旋转图标或轨道效果使用 Echo 以外的任何技术,属于 motion-blur-echo-for-turns。对任何包含文字的元素使用超过 1.05 的 smear 限值,属于 motion-blur-smear-limit。
squeeze() 和 svg() 会让图层与其子元素朝相反方向旋转,因此图层应恰好包裹一个元素。图层有两个子元素时会报错,这属于 motion-blur-one-child。
曲线沿路径运动
对于手动设置的位置,例如物理循环,每帧调用一次 blur.render(previous, current)。当运动轨迹弯曲时,改为传入路径,让副本沿曲线排列。轨道上的两个变换对应 motion-blur-follow-path。请参阅 recipes.md。
以四分之一速度播放的演示会将快门值乘以 4,因此 shutter: 720 可以保持全速时的拖影。慢动作控件仍设为 180° 对应 motion-blur-slowdown-shutter。
自定义技术会彻底清理
自定义技术会返回一个绘制器,其 clear 会移除 paint 设置的所有样式,而 destroy 会移除它添加到页面上的所有内容。在渲染过程中构建的技术也会设置 key,这样相同的技术就不会重建绘制器。静止元素上残留的滤镜,或没有 key 的内联技术,对应 motion-blur-custom-technique。请参阅 recipes.md。
按压时轻微缩放
按下控件时会缩放至 0.98,挤压或拉伸幅度保持在 0.95–1.05 以内。whileTap={{ scale: 0.8 }} 对应 motion-press-scale。列表项之间的交错延迟均小于 50ms。列表上的 staggerChildren: 0.15 对应 motion-short-stagger。分阶段播放一次的入场动画(例如标题、文本和操作项)采用 100ms 的交错延迟。常规交互不会交错播放,交错播放也不会阻塞输入。
退出动画需要存在边界
带有 exit 的条件元素应置于 AnimatePresence 内,以稳定 ID 为键,并退出至其 initial 值。由于缺少包装器而无法播放的退出动画对应 motion-exit-in-presence。请参阅 recipes.md。
来自组件库的对话框会保持挂载状态,并通过切换 open 属性控制显示。使用 open && 渲染、因此丢失关闭动画的对话框对应 motion-dialogs-stay-mounted。
尺寸根据第二个元素的内容变化
若要为容器的宽度或高度添加动画,请使用 ResizeObserver 测量内部元素,并为外部元素添加动画。在同一个节点上同时测量和添加动画,对应 motion-measure-inner。请参阅 recipes.md。
面板收起时,其内容会保持展开时的宽度,因此动画过程中不会发生重排。侧边栏关闭时,将其内容设为 w-full,对应 motion-hold-content-size。
减少动态效果会移除运动
当 prefers-reduced-motion: reduce 匹配时,滑动、缩放和模糊效果会变为不透明度交叉淡化。忽略此设置的滑动抽屉对应 motion-reduced-motion。
启用此设置时,动态模糊会移除拖影,而 reducedMotion 仍保持为 "user"。reducedMotion: "never" 对应 motion-blur-reduced-motion。
对照
每项检查都包含一对内容:问题行旁边就是替代它的行。
| 规则 | 错误示例 | 替换方案 |
|---|---|---|
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 | 缓存视图闪现骨架屏 | 立即渲染缓存视图 |
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 缩放 | 在左对齐触发器下使用 transform-origin: top left |
motion-no-scale-from-zero | 弹出层上使用 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 时关闭 |
motion-friction-at-edges | 抽屉到达顶部时戛然而止 | 拖动距离越远,移动阻尼越大 |
motion-gesture-alternative | 只能通过拖拽重新排序的行 | 在行菜单中提供 Move Up 和 Move Down |
motion-ease-by-direction | 模态框进入时使用 ease-in | 进出都使用缓出,退出时间更短 |
motion-strong-ease-out | transition: transform 200ms ease-out | transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1) |
motion-close-faster | 模态框进入和退出都为 200ms | 进入 200ms,退出 150ms |
motion-instant-hover-color | 按钮上使用 transition: background-color 200ms | 悬停颜色立即生效 |
motion-under-300ms | 下拉菜单上使用 transition={{ duration: 0.5 }} | duration: 0.2 |
motion-no-motion-on-repeat | 每次按键时使用 animate={{ scale: [1, 1.02, 1] }} | 输入时不添加动画 |
motion-tooltip-delay | 工具栏中的每个工具提示都要等待完整延迟 | 第一个等待,旁边的提示立即打开 |
motion-icon-crossfade | {copied ? <CheckIcon /> : <CopyIcon />} | 两个图标同时淡入淡出,并结合缩放和模糊效果 |
motion-icon-morph | 两个变形图标交叉淡入淡出 | 每个 motion.path 都将 d 动画变换为对应图标的路径 |
motion-blur-the-crossfade | 两个按钮标签在切换中途重叠 | 切换期间使用 filter: blur(2px) |
motion-blur-untracked-only | 轮播页面滑动时使用模糊效果 | 不使用模糊,或仅对甩出的卡片使用模糊 |
motion-blur-package | 卡片飞出时使用 filter: blur(8px) | 在卡片上使用 useMotionBlur() |
motion-blur-ref-on-mover | 在包裹 motion.div 的外层元素上使用 ref={blur.ref} | 在 motion.div 上使用 ref={blur.ref} |
motion-blur-transparent-host | 宿主元素上使用 bg-white rounded-xl | 在其第一个子元素上使用相同类名 |
motion-blur-bare-layer | 在图层上使用 rotate-3 | 在图层内部的元素上使用 rotate-3 |
motion-blur-echo-for-turns | 转圈动画中使用 technique: squeeze() | technique: echo() |
motion-blur-smear-limit | 有文本的卡片上使用 smear({ limit: 1.3 }) | 使用默认值为 1.05 的 smear(),或使用 echo() |
motion-blur-one-child | 在包含图标和标签的图层上使用 squeeze() | 将图标和标签包裹在同一个元素中 |
motion-blur-follow-path | 在轨道运动中使用 blur.render(previous, current) | blur.render((t) => at(angle + t * speed * frameTime)) |
motion-blur-slowdown-shutter | 在 shutter: 180 下演示 ¼× 慢放 | shutter: 720 |
motion-blur-custom-technique | 在渲染过程中使用 clear() {},或使用 technique: ({ layer }) => … | clear() 清除 paint 设置的所有样式,并提供一个 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 }} />} | 将相同元素放在 <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 | 在折叠侧边栏中放置 w-full 内容 | 使用能保持宽度的 w-64 内容 |
motion-reduced-motion | 在减少动态效果模式下仍滑入的抽屉 | 使用不透明度交叉淡入淡出 |
motion-blur-reduced-motion | reducedMotion: "never" | 默认的 "user" |
报告
严重性。 HIGH 表示会导致无法使用或访问。例如,永远不会播放的退出动画、会自行测量尺寸的容器,或没有按钮的手势。也包括阅读文字时出现的运动模糊,或在 squeeze() 或 svg() 下有两个子元素的图层。
MEDIUM 包括滑动页面、重放的进入动画、沿固定曲线的手势,或超过 300ms 的动画。也包括打字时的动画、错误的曲线、transition: all、切换状态上的关键帧动画,或忽略减少动画设置。还包括条件渲染的对话框、折叠时发生重排的内容、scale(0) 进入动画,或忽略轻扫手势的底部弹出层。还包括手写模糊效果、包装元素上的模糊引用、不透明的宿主,或图层上的变换。还包括 Echo 以外内容上的旋转加载指示器、超过 1.05 的拖影,或 reducedMotion: "never"。
LOW 包括淡化的悬停颜色、幅度过大的按压缩放,或过长的交错动画。也包括错误起点的动画、关闭动画和打开动画一样慢、缓慢的工具提示,或生硬的图标切换。还包括单独使用的 ease-out、没有模糊效果的交叉淡化,或没有边缘阻力的抽屉。也包括曲线上的两个变换、在全速快门下减速播放的演示,或会遗留样式或触发重建的自定义技巧。
格式。 每个根本原因列为一项:
file:line - [rule-id] what is wrong
然后提供 Rule | Count | Severity 摘要。只要仍有 HIGH,就标记为 Block;否则标记为 Approve。切勿对未阅读的文件标记 Approve。如果检查依赖时间,请播放动画;如果检查依赖拖影,请在动画速度最快处冻结画面。将所有无法运行的检查标记为 Not verified。