SKILL.md
只读
名称
motion-ui
描述
适用于 React/Next.js 的生产级 UI 动效系统。在实现动画、过渡效果或动效模式时使用。
Motion System v4.2
适用于 React / Next.js 的生产级 UI 动效系统。
核心专注于性能、无障碍访问(a11y)与实用性 —— 绝非为了装饰而加动效。
适用场景
仅在动效满足以下作用时使用该动效系统:
- 引导注意力(例如:新用户引导、关键操作)
- 传达状态变化(加载中、成功、失败、页面过渡)
- 维持空间连续性(布局变更、页面导航)
推荐场景
- 交互式组件(按钮、弹窗/Modal、菜单)
- 状态切换(加载中 → 已加载、展开 → 折叠)
- 导航与布局连续性(共享元素过渡、淡入淡出/Crossfade)
注意事项
- 无障碍访问:务必支持减弱动效(reduced motion)设置
- 设备适配:针对低配设备进行降级适配
- 性能权衡:响应速度优先于视觉流畅度
何时避免使用动效
- 纯粹为了视觉装饰
- 降低了操作易用性或视觉清晰度
- 对页面性能造成负面影响
工作原理
核心原则
动效必须满足以下至少一点:
- 引导注意力
- 传达状态
- 维持空间连续性
如果一个都不满足 → 直接删掉。
安装
npm install motion
版本区分
motion/react- 当前 Motion for React 项目的标准引入路径(包名:motion)framer-motion- 仍依赖 Framer Motion 的遗留项目的导入路径
切勿混用。 混用会导致内部调度器冲突以及 AnimatePresence 上下文失效 —— 来自一个包的组件无法与另一个包的组件协同处理退出动画。
检查项目使用的版本:
cat package.json | grep -E '"motion"|"framer-motion"'
请在整个项目中统一从单一来源引入:
// 正确(现代方式)
import { motion, AnimatePresence } from "motion/react"
// 正确(遗留方式)
import { motion, AnimatePresence } from "framer-motion"
// 绝对不要在同一个项目里混用两者
动效 Token(Motion Tokens)
// motionTokens.ts
export const motionTokens = {
duration: {
fast: 0.18,
normal: 0.35,
slow: 0.6
},
// 在 `transition` 对象的 `ease` 中传入以下配置:
// transition={{ duration: motionTokens.duration.normal, ease: motionTokens.easing.smooth }}
easing: {
smooth: [0.22, 1, 0.36, 1] as [number, number, number, number],
sharp: [0.4, 0, 0.2, 1] as [number, number, number, number]
},
distance: {
sm: 8,
md: 16,
lg: 24
}
}
使用示例:
import { motionTokens } from "@/lib/motionTokens"
<motion.div
initial={{ opacity: 0, y: motionTokens.distance.md }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: motionTokens.duration.normal,
ease: motionTokens.easing.smooth
}}
/>
性能规则
安全属性
- transform
- opacity
应避免的属性
- width / height
- top / left
黄金法则:响应速度 > 视觉流畅度
设备适配
通过结合 CPU 核心数与可用内存的启发式判断,提供更可靠的设备性能信号。deviceMemory 仅支持 Chrome/Android;回退方案可覆盖 Safari 和 Firefox。
const isLowEnd =
typeof navigator !== "undefined" && (
// 内存较低(仅 Chrome/Android 支持;其他浏览器未定义 → 视为性能足够)
(navigator.deviceMemory !== undefined && navigator.deviceMemory <= 2) ||
// 核心数少 且 无内存 API 支持(覆盖低配硬件上的 Safari/Firefox)
(navigator.deviceMemory === undefined && navigator.hardwareConcurrency <= 4)
)
const duration = isLowEnd ? 0.2 : 0.4
无障碍访问(Accessibility)
JS (useReducedMotion)
import { motion, useReducedMotion } from "motion/react"
export function FadeIn() {
const reduce = useReducedMotion()
return (
<motion.div
initial={{ opacity: 0, y: reduce ? 0 : 24 }}
animate={{ opacity: 1, y: 0 }}
/>
)
}
CSS
@media (prefers-reduced-motion: reduce) {
.motion-safe-transition {
transition: opacity 0.2s;
}
.motion-reduce-transform {
transform: none !important;
}
}
Tailwind
<div class="motion-safe:animate-fade motion-reduce:opacity-100"></div>
架构与常用模式
核心模式
| 场景 | 模式 |
|---|---|
| 悬停反馈 | whileHover |
| 点击 / 按压反馈 | whileTap |
| 滚动显现 | whileInView |
| 滚动绑定数值 | useScroll + useTransform |
| 条件挂载 / 卸载 | AnimatePresence |
| 微小布局变动(单个元素,位移 < ~300px) | layout 属性 |
| 大范围布局变动或全页重排 | 避免使用 layout;改用 CSS 过渡或页面级路由跳转 |
| 复杂的命令式动画序列 | useAnimate |
为什么避免在大容器上使用
layout? Framer 的布局动画依赖transform重新计算位置,但在占据整个视口或会触发深层重排的元素上,计算开销会导致明显的卡顿和 CLS(累积布局偏移)。建议优先使用 CSS Grid/Flexbox 过渡,或仅在具体的子元素上配合layoutId进行协调。
布局与过渡
- 共享元素过渡 →
layoutId(每个已挂载实例的 key 必须唯一) - 进入 / 退出过渡 →
AnimatePresence(参见下方的mode配置指南)
AnimatePresence 的 mode
务必显式指定 mode —— 默认的 "sync" 会同时运行进入和退出动画,在绝大多数 UI 模式下会导致视觉重叠。
mode |
何时使用 |
|---|---|
"wait" |
退出动画完全结束后才开始进入动画。适用于 模态框(Modal)、吐司提示(Toast)、页面过渡。 |
"sync"(默认) |
进入与退出动画交叠进行。仅在有意安排重叠时使用(例如淡入淡出轮播图)。 |
"popLayout" |
退出的元素会立即脱离文档流,其余元素以动画形式填补空位。适用于 列表、标签页(Tabs)、可流转/删除的卡片。 |
// 模态框 — 务必使用 "wait"
<AnimatePresence mode="wait">
{open && <Modal key="modal" />}
</AnimatePresence>
// 可删除的列表项 — 使用 "popLayout"
<AnimatePresence mode="popLayout">
{items.map(item => <Card key={item.id} />)}
</AnimatePresence>
高阶模式(概念)
- 视差滚动(绑定滚动的 transform 变化)
- 滚动叙事(粘性固定章节 / Sticky sections)
- 3D 倾斜(基于鼠标指针位置的 transform)
- 交叉淡入淡出 Crossfade(共享
layoutId) - 渐进式显现(clip-path)
- 骨架屏加载(循环 opacity 变化)
- 微交互(悬停/点击反馈)
- 弹簧物理动效(基于物理特性的动效系统)
弹窗(Modal)必备要素
- 焦点捕获(Focus trap)
- ESC 键关闭
- 背景滚动锁定
- 正确的 ARIA 角色
- 使用
AnimatePresence mode="wait"确保前一个弹窗完全退出后新弹窗才进入
完整示例
import React, { useEffect, useRef, useState } from "react"
import { motion, AnimatePresence } from "motion/react"
function useFocusTrap(ref: React.RefObject<HTMLDivElement | null>, active: boolean) {
useEffect(() => {
if (!active || !ref.current) return
const el = ref.current
const focusable = el.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const first = focusable[0]
const last = focusable[focusable.length - 1]
function handleKey(e: KeyboardEvent) {
if (e.key !== "Tab") return
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last?.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first?.focus()
}
}
el.addEventListener("keydown", handleKey)
first?.focus()
return () => el.removeEventListener("keydown", handleKey)
}, [active, ref])
}
function useScrollLock(active: boolean) {
useEffect(() => {
if (!active) return
const prev = document.body.style.overflow
document.body.style.overflow = "hidden"
return () => { document.body.style.overflow = prev }
}, [active])
}
function Modal({ open, closeModal }: { open: boolean; closeModal: () => void }) {
const ref = useRef<HTMLDivElement>(null)
useFocusTrap(ref, open)
useScrollLock(open)
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") closeModal()
}
if (open) window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [open, closeModal])
return (
// mode="wait" 确保退出动画完成后新弹窗才出现
<AnimatePresence mode="wait">
{open && (
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 flex items-center justify-center bg-black/40"
>
<motion.div
ref={ref}
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className="bg-white p-6 rounded"
>
<h2 id="modal-title">对话框标题</h2>
<button onClick={closeModal}>关闭</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
export function Example() {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>打开</button>
<Modal open={open} closeModal={() => setOpen(false)} />
</>
)
}
SSR 兼容性安全
- 确保服务端渲染与客户端渲染的初始状态完全对齐
- 避免隐式的动画起点(始终显式设置
initial属性) - 在 Next.js App Router 中,需将动效组件包裹在
"use client"预编译指令下
常见排错清单
检查项:
- 是否导错包(混用了
motion/react和framer-motion) - Next.js App Router 中是否遗漏了
"use client"标记 AnimatePresence的子组件是否缺少key属性- Hydration 不匹配(服务端 SSR 与客户端初始状态不一致)
- 在大型容器上滥用
layout属性导致重排卡顿 - 基于状态驱动的动画没有被触发(检查依赖项数组)
QA 检查项
- 零 CLS(累积布局偏移)
- 键盘交互可正常工作
- 弹窗/Modal 内焦点捕获正常
- ARIA 角色正确(
role="dialog",aria-modal="true") - 完美兼容减弱动效设置(
useReducedMotion+ CSS 媒体查询) - Next.js 中无 Hydration 警告
- 组件卸载时动画干净利落地停止(无内存泄漏)
- 所有使用到
AnimatePresence的地方都显式设置了mode
反模式(避坑指南)
- 直接对布局属性做动画(
width,height,top,left) - 无明确目的的无限循环动画(时刻自问:这个动画传达了什么状态变更?)
- 列表交错动画延时过长(
staggerChildren保持在 ≤ 0.1s;超过这个时间会显得肉、卡顿) - 忽略用户的减弱动效偏好设置
- 在超大容器或全视口容器上直接添加
layout属性 - 使用
AnimatePresence时省略mode属性(默认的"sync"会导致视觉重叠) - 纯粹为了“炫技”或装饰而添加动效
设计哲学
动效即交互设计。
终极法则
如果动效不能提升 UX(用户体验) → 直接删掉。
示例代码
按钮交互
import { motion } from "motion/react"
export function Button() {
return (
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
transition={{ duration: 0.15, ease: [0.4, 0, 0.2, 1] }}
>
点击我
</motion.button>
)
}
减弱动效示例
import { motion, useReducedMotion } from "motion/react"
export function FadeIn() {
const reduce = useReducedMotion()
return (
<motion.div
initial={{ opacity: 0, y: reduce ? 0 : 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0.1 : 0.35, ease: [0.22, 1, 0.36, 1] }}
/>
)
}
列表交错动画(Stagger List)
import { motion } from "motion/react"
const container = {
hidden: {},
visible: {
transition: { staggerChildren: 0.08 } // 保持在 ≤ 0.1s,避免感觉笨拙慢吞吞
}
}
const item = {
hidden: { opa






