Motion 基础层
Motion 系统的基础层。定义所有下游技能(motion-patterns、motion-advanced)继承的每个值、约束和规则。在任何动画工作开始前加载此技能。
何时激活
- 从头开始创建任何动画组件
- 设置令牌、弹簧预设或缓动值
- 实现
prefers-reduced-motion支持 - 调试由动画初始状态引起的 hydration 不匹配
- 评估动画是否应该存在
产出
此技能产出:
- 一个共享的
motionTokens对象(持续时间、缓动、距离、缩放) - 一个共享的
springs预设映射(5 个命名配置) - 所有组件使用的
shouldAnimate()门控函数 - 通过
useReducedMotion实现的无障碍动画默认值 - 零 hydration 警告的 SSR 安全初始状态
原则
Motion 必须至少满足以下条件之一,否则应被移除:
- 引导注意力
- 传达状态
- 保持空间连续性
响应性始终优先于流畅性。一个导致输入延迟的 60 fps 动画比没有动画更糟糕。
规则
以下规则不可协商。它们适用于系统中的每个组件。
- 仅使用
motion/react。 永远不要从framer-motion导入。切勿在同一棵树中混用两者。 initial必须与服务器输出匹配。 如果服务器渲染opacity: 1,则initial属性也必须是opacity: 1。无例外。- 减少运动覆盖一切。 当
useReducedMotion()返回true或prefersReduced为true时,所有变换被禁用。只允许使用 ≤ 0.2 秒的仅不透明度淡入淡出作为回退。 - 永远不要动画化布局属性。
animate中禁止使用width、height、top、left、margin、padding。仅使用transform和opacity。 - 所有令牌值来自
motionTokens。 禁止在组件文件中硬编码持续时间和缓动。 - 所有弹簧配置来自
springs映射。 禁止内联stiffness/damping值。 "use client"是必需的 在每个从motion/react导入的文件中。- 切勿在模块级别读取
window或navigator。 始终使用typeof window !== "undefined"进行守卫。
决策指导
选择持续时间
| 令牌 | 使用场景 |
|---|---|
instant | 工具提示显示/隐藏、焦点环、徽章更新 |
fast | 按钮反馈、图标切换、芯片切换 |
normal | 模态框打开、卡片展开、页面元素进入 |
slow | 英雄区入场、全页面过渡 |
crawl | 有意的叙事;谨慎使用 |
选择弹簧
| 预设 | 使用场景 |
|---|---|
snappy | 默认 UI — 按钮、芯片、导航项 |
gentle | 卡片、模态框、面板柔和落地 |
bouncy | 有趣时刻 — 空状态、新手引导 |
instant | 工具提示、弹出框、下拉菜单 |
release | 拖拽释放 — 自然物理感 |
何时完全禁用动画
在以下情况下禁用(使 shouldAnimate() 返回 false):
prefersReduced为trueisLowEnd为true且动画非必要- 元素在屏幕外且永远不会进入视口
- 动画纯属装饰且无 UX 目的
核心概念
令牌系统
// lib/motion-tokens.ts
export const motionTokens = {
duration: {
instant: 0.08,
fast: 0.18,
normal: 0.35,
slow: 0.6,
crawl: 1.0,
},
easing: {
smooth: [0.22, 1, 0.36, 1],
sharp: [0.4, 0, 0.2, 1],
bounce: [0.34, 1.56, 0.64, 1],
linear: [0, 0, 1, 1],
},
distance: {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 48,
},
scale: {
subtle: 0.98,
press: 0.95,
pop: 1.04,
},
}
export const springs = {
snappy: { type: "spring", stiffness: 300, damping: 30 },
gentle: { type: "spring", stiffness: 120, damping: 14 },
bouncy: { type: "spring", stiffness: 400, damping: 10 },
instant: { type: "spring", stiffness: 600, damping: 35 },
release: { type: "spring", stiffness: 200, damping: 20, restDelta: 0.001 },
}
运行时标志
// lib/motion-config.ts
export const motionConfig = {
isLowEnd() {
return (
typeof navigator !== "undefined" &&
navigator.hardwareConcurrency <= 4
)
},
prefersReduced() {
return (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
)
},
shouldAnimate({ essential = false } = {}) {
if (this.prefersReduced()) return false
if (!essential && this.isLowEnd()) return false
return true
},
duration() {
return this.isLowEnd() || this.prefersReduced()
? motionTokens.duration.instant
: motionTokens.duration.normal
},
}
无障碍
优先级顺序(从高到低):
prefers-reduced-motion: reduce— 禁用所有变换,将不透明度过渡限制为 ≤ 0.2 秒- 低端设备检测 — 减少持续时间,移除非必要动画
- 设计偏好 — 其他所有情况
Motion 必须优雅降级。绝不能让动画突然消失而导致布局偏移或使用户迷失方向。
// hooks/use-reduced-motion.tsx
"use client"
import { useReducedMotion } from "motion/react"
export function useSafeMotion(fullY: number = 16) {
const reduce = useReducedMotion()
return {
initial: { opacity: 0, y: reduce ? 0 : fullY },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: reduce ? 0 : -fullY },
}
}
/* globals.css */
@media (prefers-reduced-motion: reduce) {
.motion-safe-transition { transition: opacity 0.15s; }
.motion-reduce-transform { transform: none !important; }
}
<!-- Tailwind -->
<div class="motion-safe:animate-fade motion-reduce:opacity-100"></div>
SSR / hydration 安全
规则:initial 必须始终与服务器渲染的内容匹配。
// 错误 — 服务器渲染 opacity:1 但 initial 设为 0 → hydration 不匹配
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} />
// 正确 — 使用 AnimatePresence 或延迟到客户端挂载
"use client"
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
<motion.div
initial={{ opacity: mounted ? 0 : 1 }}
animate={{ opacity: 1 }}
/>
代码示例
端到端:令牌 + 弹簧 + 无障碍 + SSR 守卫
// components/fade-in-card.tsx
"use client"
import { useState, useEffect } from "react"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
import { useSafeMotion } from "@/hooks/use-reduced-motion"
import { motionConfig } from "@/lib/motion-config"
interface FadeInCardProps {
children: React.ReactNode
delay?: number
}
export function FadeInCard({ children, delay = 0 }: FadeInCardProps) {
// SSR 守卫 — initial 必须与服务器输出匹配(opacity: 1)
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
// 无障碍 — 当偏好减少运动时禁用变换
const safeMotion = useSafeMotion(motionTokens.distance.md)
// 设备门控 — 在低端硬件上跳过动画
if (!motionConfig.shouldAnimate() || !mounted) {
return <div>{children}</div>
}
return (
<motion.div
initial={safeMotion.initial}
animate={safeMotion.animate}
exit={safeMotion.exit}
transition={{
...springs.gentle,
delay,
}}
whileHover={{ scale: motionTokens.scale.pop }}
whileTap={{ scale: motionTokens.scale.press }}
>
{children}
</motion.div>
)
}
约束 / 非目标
此技能不涵盖:
- UI 组件模式(按钮、模态框、交错)→ 参见
motion-patterns - 拖拽、手势、SVG、文本动画、自定义钩子 → 参见
motion-advanced - 纯 CSS 动画或 Tailwind
animate-*类(未使用motion/react) - 第三方动画库(GSAP、anime.js 等)
- 动画设计决策(何时动画、强调什么)— 这是设计问题,而非代码约束
反模式
| 反模式 | 违反规则 | 修复 |
|---|---|---|
import { motion } from "framer-motion" | 规则 1 | 使用 motion/react |
initial={{ opacity: 0 }} 在 SSR 组件上 | 规则 2 | 添加挂载守卫 |
跳过 useReducedMotion 检查 | 规则 3 | 使用 useSafeMotion 钩子 |
animate={{ width: "100%" }} | 规则 4 | 改用 scaleX 变换 |
内联 transition={{ duration: 0.4 }} | 规则 5 | 使用 motionTokens.duration.normal |
内联 { stiffness: 300, damping: 30 } | 规则 6 | 使用 springs.snappy |
缺少 "use client" 指令 | 规则 7 | 添加到文件顶部 |
在模块级别使用 navigator.hardwareConcurrency | 规则 8 | 包裹在 typeof navigator !== "undefined" 中 |
相关技能
motion-patterns— 使用此处定义的令牌和弹簧来构建按钮、模态框、交错、页面过渡和滚动模式。不会重新定义任何值。motion-advanced— 使用此处定义的令牌和弹簧来处理拖拽、SVG、文本和手势模式。在此基础之上添加useAnimate序列和自定义钩子。