motion-patterns

motion-patterns

热门

适用于 React / Next.js 的开箱即用动画范式——覆盖按钮、弹窗、Toast 提示、交错动画、页面过渡、退场动画、滚动联动及布局动画——基于 motion-foundations 的 Design Tokens 与 Spring 弹簧参数构建。

24万Star
3.6万Fork
更新于 2026/8/3
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 的滚动显现与滚动联动范式
  • 用于元素展开与交叉淡入淡出的布局动画范式(layoutlayoutId

设计原则

  • 所有范式一律从 motion-foundations 导入配置,坚决不写硬编码数值(Magic Numbers)。
  • 任何条件渲染必须由 AnimatePresence 包裹并绑定唯一的 key
  • 定义进场动画时必须同步定义退场动画,严禁遗漏退场逻辑。
  • layout 仅用于局部、小范围的位移变动;大面积 DOM 树应使用显式的 Transform。

规范法则

  1. 条件渲染的直接子节点必须包裹在带 keyAnimatePresence 中。 缺少 key 会导致退场动画彻底失效。
  2. 只要设置了 initial + animate,就必须同步配置 exit 没有退场的动画是不完整的。
  3. 页面过渡动画必须指定 mode="wait" 确保前一页退场完毕后再触发新页进场。
  4. 子节点数超过 5 个或存在深层 DOM 嵌套时,严禁使用 layout 请改用显式的 x/y 位移变换。
  5. 交错动画的时间间隔应保持在 0.05s0.10s 之间。 低于该值显得机械死板,高于该值会带来拖沓感。
  6. 弹窗(Modal)必须具备以下标准配置: 焦点捕获(Focus trap)、Esc 键关闭、背景滚动锁定、role="dialog"aria-modal="true"
  7. 滚动显现必须使用 viewport={{ once: true }} 移出视角后重复触发只会干扰视线,毫无正面价值。
  8. 所有 Token 数值一律从 motion-foundations 导入。 严禁内联硬编码数字。

决策指南

选择合适的动画范式

场景 对应范式
元素的显隐切换 AnimatePresence
列表项按顺序依次加载入场 Stagger 变体(Variants)
路由页面间相互切换 页面过渡 Wrapper
元素原位改变尺寸 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 —— 将两个独立的元素关联起来,在重渲染时实现跨元素的平滑交叉淡入淡出(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 -->