motion-patterns
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseMotion Patterns
Motion 动画模式
Copy-paste patterns for the most common UI animation needs.
Every pattern here is built on tokens and springs.
Do not define new duration or easing values here — import them.
motion-foundations以下是可直接复制粘贴的、满足最常见UI动画需求的模式。
这里的每个模式都基于的tokens和springs构建。
请勿在此处定义新的时长或缓动值——请直接导入使用。
motion-foundationsWhen to Activate
适用场景
- Animating a button, card, modal, or toast notification
- Building list entrances with stagger
- Setting up page transitions in Next.js App Router
- Adding entrance or exit animations to conditional content
- Implementing scroll-reveal, scroll-linked progress, or sticky story sections
- Building expanding cards, accordions, or shared-element transitions
- 为按钮、卡片、模态框或提示通知添加动画
- 构建带有序列效果的列表入场动画
- 在Next.js App Router中设置页面过渡效果
- 为条件渲染内容添加入场或退出动画
- 实现滚动显示、滚动关联进度或粘性故事板块
- 构建可展开卡片、折叠面板或共享元素过渡效果
Outputs
产出内容
This skill produces:
- Accessible, SSR-safe animation for all standard UI components
- -wrapped conditional renders with correct exit behavior
AnimatePresence - Page transition wrapper component for Next.js App Router
- Scroll-reveal and scroll-linked patterns using +
useScrolluseTransform - Layout animation patterns (,
layout) for expanding and crossfading elementslayoutId
本技能可生成:
- 适用于所有标准UI组件的无障碍、支持SSR的动画
- 包裹的条件渲染组件,具备正确的退出行为
AnimatePresence - 用于Next.js App Router的页面过渡包装组件
- 使用+
useScroll实现的滚动显示和滚动关联模式useTransform - 用于元素展开和淡入淡出的布局动画模式(、
layout)layoutId
Principles
设计原则
- Every pattern imports from . No raw numbers.
motion-foundations - Every conditional render is wrapped in with a
AnimatePresence.key - Exit animations are always defined alongside enter animations — never as an afterthought.
- is used only for small, isolated shifts. Large subtrees get explicit transforms.
layout
- 每个模式都从导入配置,不使用原始数值。
motion-foundations - 每个条件渲染都用包裹,并为直接子元素设置
AnimatePresence。key - 退出动画始终与入场动画一同定义——绝不事后补充。
- 仅用于小型、独立的位置偏移。大型子树需使用显式变换。
layout
Rules
规则要求
- Always wrap conditional renders in with a
AnimatePresenceon the direct child. Without a key, exit animations never fire.key - Always define when defining
exit+initial. An animation without an exit is incomplete.animate - Use on page transitions. Enter must not start until exit completes.
mode="wait" - Never use on subtrees with more than ~5 children or deeply nested DOM. Use explicit
layout/xtransforms instead.y - Stagger interval must stay between and
0.05s. Below feels mechanical; above feels sluggish.0.10s - Modals must always include: focus trap, Escape-key close, scroll lock, ,
role="dialog".aria-modal="true" - Scroll reveals use . Repeating on scroll-out is distracting, not informative.
viewport={{ once: true }} - All token values are imported from . No inline numbers.
motion-foundations
- 始终用包裹条件渲染,并为直接子元素设置
AnimatePresence。没有key的话,退出动画将无法触发。key - 定义+
initial时必须同时定义animate。缺少退出动画的动画是不完整的。exit - 页面过渡需使用。入场动画必须等待退出动画完成后再开始。
mode="wait" - 绝不在包含超过约5个子元素或深度嵌套DOM的子树上使用。改用显式的
layout/x变换。y - 序列间隔必须保持在到
0.05s之间。低于此值会显得机械,高于此值会显得迟缓。0.10s - 模态框必须包含以下内容:焦点陷阱、ESC键关闭、滚动锁定、、
role="dialog"。aria-modal="true" - 滚动显示需使用。滚动出视图时重复触发动画会分散注意力,无实际信息价值。
viewport={{ once: true }} - 所有token值均从导入。不使用内联数值。
motion-foundations
Decision Guidance
决策指南
Choosing the right pattern
选择合适的模式
| Situation | Pattern |
|---|---|
| Element appears / disappears | |
| List of items loading in sequence | Stagger variants |
| Navigating between routes | Page transition wrapper |
| Element changes size in place | |
| Same element moves across page contexts | |
| Element enters when scrolled into view | |
| Value tied to scroll position | |
| 场景 | 模式 |
|---|---|
| 元素出现/消失 | |
| 列表项按顺序加载 | 序列变体(Stagger variants) |
| 路由间导航 | 页面过渡包装组件 |
| 元素在原位改变尺寸 | |
| 同一元素在不同页面上下文间移动 | |
| 元素滚动进入视图时触发入场 | |
| 值与滚动位置关联 | |
When to use mode="wait"
vs mode="sync"
mode="wait"mode="sync"何时使用mode="wait"
vs mode="sync"
mode="wait"mode="sync"| Mode | Use when |
|---|---|
| Page transitions, content swaps (one at a time) |
| Stacked notifications, list items (overlap is fine) |
| Items removed from a reflow list |
| 模式 | 适用场景 |
|---|---|
| 页面过渡、内容切换(一次仅显示一个) |
| 堆叠通知、列表项(允许重叠) |
| 从回流列表中移除项时 |
Core Concepts
核心概念
AnimatePresence contract
AnimatePresence 约定
Three things must always be true:
- wraps the conditional
AnimatePresence - The direct child has a
key - The child has an prop
exit
Miss any one of these and the exit animation silently fails.
必须同时满足以下三点:
- 包裹条件渲染
AnimatePresence - 直接子元素拥有
key - 子元素拥有属性
exit
缺少任何一点,退出动画都会静默失效。
layout vs layoutId
layout vs layoutId
- — animates the element's own size/position change in place
layout - — links two separate elements, crossfading between them across renders
layoutId
Use on text inside an expanding container to prevent text reflow from animating.
layout="position"- —— 原地动画元素自身的尺寸/位置变化
layout - —— 关联两个独立元素,在渲染间实现淡入淡出过渡
layoutId
在可展开容器内的文本上使用,可防止文本重排被动画化。
layout="position"Code Examples
代码示例
Button feedback
按钮反馈
tsx
"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}
/>tsx
"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
序列列表
tsx
"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
const container = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.08, // within the 0.05–0.10 rule
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>tsx
"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
const container = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.08, // within the 0.05–0.10 rule
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
模态框
tsx
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
// Wrap at the call site:
// <AnimatePresence>{isOpen && <Modal key="modal" />}</AnimatePresence>
export function Modal({ onClose }: { onClose: () => void }) {
return (
<>
{/* Overlay */}
<motion.div
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Panel — accessibility requirements: focus trap, Escape close,
scroll lock, 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}
/>
</>
)
}tsx
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
// Wrap at the call site:
// <AnimatePresence>{isOpen && <Modal key="modal" />}</AnimatePresence>
export function Modal({ onClose }: { onClose: () => void }) {
return (
<>
{/* Overlay */}
<motion.div
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Panel — accessibility requirements: focus trap, Escape close,
scroll lock, 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 stack
提示框堆叠
tsx
"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>tsx
"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>Page transition (Next.js App Router)
页面过渡(Next.js App Router)
tsx
// 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>
)
}tsx
// 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
滚动显示
tsx
"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 — rule 7
transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
/>tsx
"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 — rule 7
transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
/>Scroll progress bar
滚动进度条
tsx
"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 }}
/>
)
}tsx
"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 }}
/>
)
}Expanding card
可展开卡片
tsx
"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" prevents text reflow from animating */}
<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>
)
}tsx
"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" prevents text reflow from animating */}
<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>
)
}Shared-element crossfade
共享元素淡入淡出
tsx
// Source context
<motion.img layoutId="hero-image" src={src} className="w-16 h-16 rounded" />
// Destination context (same layoutId — motion handles the transition)
<motion.img layoutId="hero-image" src={src} className="w-full rounded-xl" />tsx
// Source context
<motion.img layoutId="hero-image" src={src} className="w-16 h-16 rounded" />
// Destination context (same layoutId — motion handles the transition)
<motion.img layoutId="hero-image" src={src} className="w-full rounded-xl" />Accordion
折叠面板
tsx
<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>tsx
<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>End-to-End Example
端到端示例
A staggered list that enters on mount, handles conditional presence, and
respects reduced motion — combining tokens, springs, AnimatePresence, and
the accessibility hook from :
motion-foundationstsx
"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>
)
}一个在挂载时入场、支持条件渲染、并遵循减少动画偏好的序列列表——结合了tokens、springs、AnimatePresence和中的无障碍钩子:
motion-foundationstsx
"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>
)
}Constraints / Non-Goals
约束/非目标
This skill does not cover:
- Token and spring definitions → see
motion-foundations - Drag interactions, swipe gestures, reorderable lists → see
motion-advanced - Text animations (word/character reveal, counters) → see
motion-advanced - SVG path drawing or morphing → see
motion-advanced - Custom animation hooks → see
motion-advanced - CSS-only transitions not using
motion/react
本技能不涵盖以下内容:
- Token和spring的定义 → 请查看
motion-foundations - 拖拽交互、滑动手势、可重新排序列表 → 请查看
motion-advanced - 文本动画(单词/字符显示、计数器) → 请查看
motion-advanced - SVG路径绘制或变形 → 请查看
motion-advanced - 自定义动画钩子 → 请查看
motion-advanced - 不使用的纯CSS过渡
motion/react
Anti-Patterns
反模式
| Anti-pattern | Rule violated | Fix |
|---|---|---|
| Rule 1 | Add stable |
| Rule 2 | Always define all three together |
Page transition without | Rule 3 | Add |
| Rule 4 | Use |
| Rule 5 | Cap at |
| Modal without focus trap | Rule 6 | Add |
| Rule 7 | Repeating entrances distract, not inform |
| Rule 8 | Use |
| 反模式 | 违反规则 | 修复方案 |
|---|---|---|
| 规则1 | 为直接子元素添加稳定的 |
仅定义 | 规则2 | 始终同时定义三者 |
页面过渡未使用 | 规则3 | 为 |
在包含50个项的列表上使用 | 规则4 | 使用 |
10项列表使用 | 规则5 | 将值限制在 |
| 模态框缺少焦点陷阱 | 规则6 | 添加 |
| 规则7 | 重复入场动画会分散注意力,无实际价值 |
内联设置 | 规则8 | 使用 |
Related Skills
相关技能
- — defines all tokens, springs, the
motion-foundationshook, and SSR guards that every pattern here imports. Must be set up first.useSafeMotion - — extends these patterns with drag, gestures, SVG, text, custom hooks, and imperative sequencing. Does not redefine any patterns from this skill.
motion-advanced
- —— 定义了本技能中所有模式导入的tokens、springs、
motion-foundations钩子和SSR防护。必须先完成该技能的配置。useSafeMotion - —— 在本技能的基础上扩展了拖拽、手势、SVG、文本、自定义钩子和命令式序列功能。不会重定义本技能中的任何模式。
motion-advanced