motion-ui

motion-ui

热门

适用于 React/Next.js 的生产级 UI 动效系统。在实现动画、过渡效果或动效模式时使用。

24万Star
3.6万Fork
更新于 2026/8/2
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/reactframer-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