SKILL.md
只读
名称
motion-patterns
描述
适用于 React / Next.js 的开箱即用动画范式——覆盖按钮、弹窗、Toast 提示、交错动画、页面过渡、退场动画、滚动联动及布局动画——基于 motion-foundations 的 Design Tokens 与 Spring 弹簧参数构建。
版本
1.0
Motion Patterns
针对最常见 UI 动画需求打造的即插即用范式。
此处每个范式均基于 motion-foundations 的 Design Tokens 和弹簧参数构建。
切勿在此硬编码定义新的时长或缓动值——请直接导入使用。
何时启用
- 为按钮、卡片、弹窗或 Toast 通知的交互添加动效
- 实现带有阶梯交错(Stagger)效果的列表展现动画
- 配置 Next.js App Router 的页面切换过渡效果
- 为条件渲染的内容添加进场或退场动画
- 实现滚动显现(Scroll-reveal)、滚动进度联动或吸顶故事切片(Sticky Story)
- 打造可展开卡片、手风琴折叠面板或共享元素过渡(Shared-element transitions)
产出内容
本 Skill 可提供:
- 兼顾无障碍(a11y)与 SSR 安全的通用 UI 组件动画方案
- 包裹了
AnimatePresence且具备规范退场行为的条件渲染模式 - 适配 Next.js App Router 的页面过渡 Wrapper 组件
- 基于
useScroll+useTransform的滚动显现与滚动联动范式 - 用于元素展开与交叉淡入淡出的布局动画范式(
layout、layoutId)
设计原则
- 所有范式一律从
motion-foundations导入配置,坚决不写硬编码数值(Magic Numbers)。 - 任何条件渲染必须由
AnimatePresence包裹并绑定唯一的key。 - 定义进场动画时必须同步定义退场动画,严禁遗漏退场逻辑。
layout仅用于局部、小范围的位移变动;大面积 DOM 树应使用显式的 Transform。
规范法则
- 条件渲染的直接子节点必须包裹在带
key的AnimatePresence中。 缺少 key 会导致退场动画彻底失效。 - 只要设置了
initial+animate,就必须同步配置exit。 没有退场的动画是不完整的。 - 页面过渡动画必须指定
mode="wait"。 确保前一页退场完毕后再触发新页进场。 - 子节点数超过 5 个或存在深层 DOM 嵌套时,严禁使用
layout。 请改用显式的x/y位移变换。 - 交错动画的时间间隔应保持在
0.05s到0.10s之间。 低于该值显得机械死板,高于该值会带来拖沓感。 - 弹窗(Modal)必须具备以下标准配置: 焦点捕获(Focus trap)、Esc 键关闭、背景滚动锁定、
role="dialog"及aria-modal="true"。 - 滚动显现必须使用
viewport={{ once: true }}。 移出视角后重复触发只会干扰视线,毫无正面价值。 - 所有 Token 数值一律从
motion-foundations导入。 严禁内联硬编码数字。
决策指南
选择合适的动画范式
| 场景 | 对应范式 |
|---|---|
| 元素的显隐切换 | AnimatePresence |
| 列表项按顺序依次加载入场 | Stagger 变体(Variants) |
| 路由页面间相互切换 | 页面过渡 Wrapper |
| 元素原位改变尺寸 | layout 属性 |
| 同一元素在不同页面/上下文间跨越移动 | layoutId |
| 元素滚动至视口内时触发入场 | whileInView |
| 动效数值与滚动位置实时联动 | useScroll + useTransform |
模式选择:mode="wait" vs mode="sync"
| 模式 | 适用场景 |
|---|---|
wait |
页面切换、单控件内容替换(同一时刻只保留一个内容) |
sync |
堆叠通知、列表项变动(允许动画重叠) |
popLayout |
从重排列表中移除元素 |
核心概念
AnimatePresence 必备三要素
必须时刻满足以下三条硬性约定:
- 由
AnimatePresence包裹条件渲染逻辑 - 直接子节点拥有唯一的
key - 该子节点显式配置了
exit属性
遗漏其中任意一项,退场动画都会静默失效。
layout vs layoutId
layout—— 原位平滑过渡元素自身的尺寸或位置变化layoutId—— 将两个独立的元素关联起来,在重渲染时实现跨元素的平滑交叉淡入淡出(Crossfade)
在可展开容器内的文本元素上使用 layout="position",可以防止文本重排(Reflow)过程被错误地应用补间动画。
代码示例
按钮反馈
"use client"
import { motion } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"
<motion.button
whileHover={{ scale: motionTokens.scale.pop }}
whileTap={{ scale: motionTokens.scale.press }}
transition={springs.snappy}
/>
交错列表(Stagger list)
"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
const container = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.08, // 遵循 0.05–0.10s 的时间间隔规范
delayChildren: 0.1,
},
},
}
const item = {
hidden: { opacity: 0, y: motionTokens.distance.md },
visible: { opacity: 1, y: 0, transition: springs.gentle },
}
<motion.ul variants={container} initial="hidden" animate="visible">
{items.map((i) => (
<motion.li key={i.id} variants={item} />
))}
</motion.ul>
弹窗(Modal)
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
// 调用处包裹逻辑:
// <AnimatePresence>{isOpen && <Modal key="modal" />}</AnimatePresence>
export function Modal({ onClose }: { onClose: () => void }) {
return (
<>
{/* 遮罩层 */}
<motion.div
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* 弹窗主体 —— 无障碍规范要求:焦点捕获、Esc 键关闭、
滚动锁定、role="dialog"、aria-modal="true" */}
<motion.div
role="dialog"
aria-modal="true"
className="fixed inset-x-4 top-1/2 -translate-y-1/2 rounded-xl bg-white p-6"
initial={{
opacity: 0,
scale: motionTokens.scale.press,
y: motionTokens.distance.sm,
}}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{
opacity: 0,
scale: motionTokens.scale.press,
y: motionTokens.distance.sm,
}}
transition={springs.gentle}
/>
</>
)
}
Toast 堆叠队列
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
<AnimatePresence mode="sync">
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{
opacity: 0,
x: motionTokens.distance.xl,
scale: motionTokens.scale.subtle,
}}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{
opacity: 0,
x: motionTokens.distance.xl,
scale: motionTokens.scale.subtle,
}}
transition={springs.snappy}
/>
))}
</AnimatePresence>
页面过渡(Next.js App Router)
// components/page-transition.tsx
"use client"
import { motion, AnimatePresence } from "motion/react"
import { usePathname } from "next/navigation"
import { motionTokens } from "@/lib/motion-tokens"
const variants = {
initial: { opacity: 0, y: motionTokens.distance.sm },
enter: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -motionTokens.distance.sm },
}
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
variants={variants}
initial="initial"
animate="enter"
exit="exit"
transition={{
duration: motionTokens.duration.normal,
ease: motionTokens.easing.smooth,
}}
>
{children}
</motion.div>
</AnimatePresence>
)
}
滚动显现(Scroll reveal)
"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
<motion.div
initial={{ opacity: 0, y: motionTokens.distance.lg }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }} // once: true —— 遵循规则 7
transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
/>
滚动进度条
"use client"
import { motion, useScroll } from "motion/react"
export function ScrollProgress() {
const { scrollYProgress } = useScroll()
return (
<motion.div
className="fixed top-0 left-0 h-1 bg-indigo-500 origin-left w-full"
style={{ scaleX: scrollYProgress }}
/>
)
}
可展开卡片
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"
export function ExpandingCard({ title, body }: { title: string; body: string }) {
const [expanded, setExpanded] = useState(false)
return (
<motion.div layout onClick={() => setExpanded(!expanded)} className="cursor-pointer">
{/* layout="position" 可防止文本重排时产生错误的补间动画 */}
<motion.h2 layout="position" className="font-semibold">
{title}
</motion.h2>
<AnimatePresence>
{expanded && (
<motion.p
key="body"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: motionTokens.duration.fast }}
>
{body}
</motion.p>
)}
</AnimatePresence>
</motion.div>
)
}
共享元素交叉淡入淡出
// 源上下文
<motion.img layoutId="hero-image" src={src} className="w-16 h-16 rounded" />
// 目标上下文(相同 layoutId —— motion 将自动处理过渡)
<motion.img layoutId="hero-image" src={src} className="w-full rounded-xl" />
手风琴折叠面板
<motion.div
initial={false}
animate={{ opacity: open ? 1 : 0, scaleY: open ? 1 : 0 }}
style={{ transformOrigin: "top", overflow: "hidden" }}
transition={{
duration: motionTokens.duration.normal,
ease: motionTokens.easing.smooth,
}}
> {children}
</motion.div>
端到端完整示例
一个在挂载时渐次入场、支持条件显隐退场、并兼顾减弱运动(reduced motion)无障碍设定的交错列表——综合运用了 motion-foundations 的 Tokens、Springs、AnimatePresence 以及无障碍 Hook:
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
import { useSafeMotion } from "@/hooks/use-reduced-motion"
const containerVariants = {
hidden: {},
visible: {
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
},
}
function ListItem({ label, onRemove }: { label: string; onRemove: () => void }) {
const safe = useSafeMotion(motionTokens.distance.sm)
return (
<motion.li
variants={{
hidden: safe.initial,
visible: safe.animate,
}}
exit={safe.exit}
transition={springs.gentle}
className="flex items-center justify-between p-3 rounded-lg bg-white shadow-sm"
>
<span>{label}</span>
<button onClick={onRemove}>Remove</button>
</motion.li>
)
}
export function AnimatedList({ items, onRemove }: {
items: { id: string; label: string }[]
onRemove: (id: string) => void
}) {
return (
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-2"
>
<AnimatePresence mode="popLayout">
{items.map((item) => (
<ListItem
key={item.id}
label={item.label}
onRemove={() => onRemove(item.id)}
/>
))}
</AnimatePresence>
</motion.ul>
)
}
边界与非目标(Non-Goals)
本 Skill 不包含以下内容:
- Token 与 Spring 基础定义 → 参见
motion-foundations - 拖拽交互、滑动手势、可排序列表 → 参见
motion-advanced - 文本动画(逐字/逐词显现、数字计数器) → 参见
motion-advanced - SVG 路径绘制或变形(Morphing) → 参见
motion-advanced - 自定义动画 Hooks → 参见
motion-advanced - 纯 CSS 过渡
<!-- truncated for translation batch; full body continues in source -->






