Communitygithub.com

KilianBStar/motion-patterns

适用于 React / Next.js 的生产级动画模式库——按钮、模态框、提示框、交错列表、页面过渡、退出动画、滚动和布局动画,均基于 motion-foundations 令牌和弹簧构建。在 React 或 Next.js 中为特定 UI 元素(按钮、模态框、提示框、交错列表、页面过渡或滚动)添加动画时使用。

motion-patterns 是什么?

Motion Patterns 是一个专业的 UI 动画模式集合,专为 React 和 Next.js 应用设计。该技能提供了经过实战检验的动画片段,覆盖了最常见的 UI 动画需求,包括按钮反馈、模态框、提示框堆栈、交错列表、页面过渡、滚动揭示、展开卡片和共享元素过渡。所有这些模式都严格基于 motion-foundations 令牌和弹簧,禁止内联的持续时间和缓动值定义,从而确保动画风格的一致性和可维护性。该技能明确规定了八条核心规则,例如“始终将条件渲染包裹在 AnimatePresence 中并附带 key”、“必须为条件渲染定义 exit 动画”,以及“在 AnimatePresence 中为条件渲染定义 exit 动画”。此外,它还提供了详细的决策指南,帮助开发者根据具体场景选择正确的模式,并指出了需要避免的反模式。该技能适用于任何需要在 React 应用中实现流畅、可访问且 SSR 安全的动画的开发场景。

兼容平台~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/KilianBStar/mes-skills-/tree/HEAD/.claude/skills/motion-patterns

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

Motion Patterns

复制粘贴即可使用的常见 UI 动画模式。 此处的每个模式都基于 motion-foundations 令牌和弹簧构建。 请勿在此定义新的持续时间和缓动值——应从 motion-foundations 中导入。

何时激活

  • 为按钮、卡片、模态框或提示框通知添加动画
  • 通过交错效果构建列表入场
  • 在 Next.js App Router 中设置页面过渡
  • 为条件渲染内容添加入场或退场动画
  • 实现滚动显示、滚动关联进度或粘性故事章节
  • 构建展开卡片、手风琴或共享元素过渡

产出

本技能产出:

  • 适用于所有标准 UI 组件的、可访问且 SSR 安全的动画
  • 使用 AnimatePresence 包裹的条件渲染,并具有正确的退场行为
  • 适用于 Next.js App Router 的页面过渡包装器组件
  • 使用 useScroll + useTransform 实现的滚动显示和滚动关联模式
  • 用于展开和交叉淡入元素的布局动画模式(layout、layoutId)

原则

  • 每个模式都从 motion-foundations 导入。没有原始数字。
  • 每个条件渲染都包裹在 AnimatePresence 中,并附带一个 key。
  • 退场动画始终与入场动画一起定义——绝不事后添加。
  • layout 仅用于小的、孤立的位移。大的子树使用显式变换。

规则

  1. 始终将条件渲染包裹在 AnimatePresence 中,并直接子元素附带 key。没有 key,退场动画永远不会触发。
  2. 定义 initial + animate 时,始终同时定义 exit。没有退场动画的动画是不完整的。
  3. 在页面过渡中使用 mode="wait"。进入动画必须在退场动画完成后才开始。
  4. 切勿在超过约 5 个子元素或深层嵌套 DOM 的子树上使用 layout。应使用显式的 x/y 变换来代替。
  5. 交错间隔必须保持在 0.05s 到 0.10s 之间。低于此值会显得机械,高于此值会显得迟缓。
  6. 模态框必须始终包含:焦点陷阱、Escape 键关闭、滚动锁定、role="dialog"、aria-modal="true"。
  7. 滚动显示使用 viewport={{ once: true }}。滚动出去后重复播放会分散注意力,而不是提供信息。
  8. 所有令牌值都从 motion-foundations 导入。没有内联数字。

决策指导

选择正确的模式

情况模式
元素出现 / 消失AnimatePresence
列表项按顺序加载交错变体
在路由之间导航页面过渡包装器
元素在原位置改变大小layout 属性
同一元素跨页面上下文移动layoutId
元素滚动到视图中时进入whileInView
值与滚动位置关联useScroll + useTransform

何时使用 mode="wait" vs mode="sync"

模式使用时机
wait页面过渡、内容切换(一次一个)
sync堆叠通知、列表项(重叠是允许的)
popLayout从重排列表中移除项目

核心概念

AnimatePresence 契约

必须始终满足三个条件:

  1. AnimatePresence 包裹条件渲染
  2. 直接子元素有 key
  3. 子元素有 exit 属性

缺少其中任何一个,退场动画都会静默失败。

layout vs layoutId

  • layout —— 动画化元素自身在原位置的尺寸/位置变化
  • layoutId —— 连接两个独立的元素,在渲染之间进行交叉淡入

在展开容器内的文本上使用 layout="position" 可以防止文本重排产生动画。

代码示例

按钮反馈

"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}
/>

交错列表

"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.10 规则范围内
      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>

模态框

"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}
      />

      {/* 面板 —— 无障碍要求:焦点陷阱、Escape 关闭、
          滚动锁定、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}
      />
    </>
  )
}

提示框堆栈

"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>
  )
}

滚动显示

"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>

端到端示例

一个在挂载时进入的交错列表,处理条件存在性,并尊重减少运动偏好 —— 结合了令牌、弹簧、AnimatePresence 以及来自 motion-foundations 的无障碍钩子:

"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}>删除</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>
  )
}

限制 / 非目标

本技能不涵盖:

  • 令牌和弹簧定义 → 见 motion-foundations
  • 拖拽交互、滑动手势、可重新排序列表 → 见 motion-advanced
  • 文本动画(逐词/逐字显示、计数器) → 见 motion-advanced
  • SVG 路径绘制或变形 → 见 motion-advanced
  • 自定义动画 Hook → 见 motion-advanced
  • 不使用 motion/react 的纯 CSS 过渡

反模式

反模式违反的规则修复方法
AnimatePresence 子元素缺少 key规则 1为直接子元素添加稳定的 key
initial + animate 没有 exit规则 2始终同时定义所有三个
页面过渡没有 mode="wait"规则 3在 AnimatePresence 上添加 mode="wait"
在包含 50 个项目的列表上使用 layout规则 4使用 mode="popLayout" 或显式变换
在包含 10 个项目的列表上使用 staggerChildren: 0.2规则 5限制在 0.08–0.10
没有焦点陷阱的模态框规则 6添加 focus-trap-react 或 Radix Dialog
whileInView 没有 viewport={{ once: true }}规则 7重复进入会分散注意力,而不是提供信息
内联 transition={{ duration: 0.3 }}规则 8使用 motionTokens.duration.normal

相关技能

  • motion-foundations —— 定义了此处的每个模式都会导入的所有令牌、弹簧、useSafeMotion 钩子和 SSR 守卫。必须首先设置。
  • motion-advanced —— 通过拖拽、手势、SVG、文本、自定义钩子和命令式排序来扩展这些模式。不会重新定义本技能中的任何模式。

相关技能