motion-foundations

motion-foundations

热门

基于 motion/react 实现 React / Next.js 的动效 Token、弹簧预设、性能规范、设备适配、无障碍强制约束及 SSR 水合安全保障。这是动效系统的底层基石——所有其他 Motion Skill 均依赖此技能。

24万Star
3.6万Fork
更新于 2026/8/3
SKILL.md
只读
名称
motion-foundations
描述

基于 motion/react 实现 React / Next.js 的动效 Token、弹簧预设、性能规范、设备适配、无障碍强制约束及 SSR 水合安全保障。这是动效系统的底层基石——所有其他 Motion Skill 均依赖此技能。

版本
1.0

Motion Foundations

动效系统的底层基石。定义了下游 Skill(motion-patternsmotion-advanced)继承的所有数值、约束和规则。在开启任何动画开发工作之前,必须先加载此 Skill。

何时激活

  • 从零开始开发任何带有动画效果的组件
  • 配置 Token、弹簧预设(spring presets)或缓动值(easing values)
  • 实现 prefers-reduced-motion 减弱动效支持
  • 排查由于动画初始状态导致的 SSR 水合不匹配(hydration mismatch)问题
  • 评估某个动画效果是否真的有必要存在

输出产物

此 Skill 包含以下核心产物:

  • 共享的 motionTokens 对象(包含时长、缓动曲线、距离和缩放比例)
  • 共享的 springs 预设映射表(包含 5 组预设配置)
  • 通用的 shouldAnimate() 动画开关校验方法(供所有组件调用)
  • 基于 useReducedMotion 实现符合无障碍规范的动画默认行为
  • 零 Hydration 警告的 SSR 安全初始状态

核心设计理念

动效必须至少满足以下条件之一,否则必须直接移除:

  • 引导用户注意力
  • 传递状态变化
  • 保持空间连续性

响应速度(Responsiveness)永远高于流畅度(Smoothness)。如果一个 60 fps 的动画导致了输入延迟,那它的表现甚至不如没有动画。

强约束规则

以下规则属于不可违背的硬性要求,适用于系统中的每一个组件:

  1. 仅限使用 motion/react。绝对不要从 framer-motion 导入。绝对不要在同一个组件树中混合使用两者。
  2. initial 状态必须与服务端渲染输出完全一致。如果服务端渲染的是 opacity: 1,那么 initial 属性也必须是 opacity: 1,无一例外。
  3. 减弱动效(Reduced Motion)优先于一切。当 useReducedMotion() 返回 trueprefersReducedtrue 时,禁用所有 Transform 变形效果。唯一允许的降级方案是时长不超过 0.2s 的仅淡入淡出(Opacity-only fade)。
  4. 严禁对布局属性(Layout properties)做动画widthheighttopleftmarginpadding 严禁出现在 animate 中,只能使用 transformopacity
  5. 所有 Token 数值必须取自 motionTokens。严禁在组件文件中硬编码(Hardcode)动画时长和缓动参数。
  6. 所有 Spring 配置必须取自 springs 预设映射表。严禁内联写死 stiffness / damping 参数。
  7. 必须在所有导入 motion/react 的文件顶部添加 "use client" 指令
  8. 严禁在模块层级直接读取 windownavigator。必须始终使用 typeof window !== "undefined" 进行环境守护防御。

决策指南

选定时长(Duration)

Token 适用场景
instant Tooltip 提示框显示/隐藏、焦点环(Focus ring)、Badge 徽章更新
fast 按钮点击反馈、图标切换、Chip 标签状态切换
normal 弹窗 Modal 打开、卡片展开、页面元素进场
slow Hero 区域展示动画、整页切换(Full-page transition)
crawl 沉浸式叙事动画;请谨慎使用

选定弹簧(Spring)

预设 适用场景
snappy 默认 UI 控件——按钮、Chip 标签、导航项
gentle 卡片、Modal 弹窗、侧边面板的柔和落位动画
bouncy 趣味交互时刻——空状态展示、新用户引导流程
instant Tooltip 提示、Popover 弹出框、Dropdown 下拉菜单
release 拖拽释放——自然符合物理直觉的手感

何时彻底禁用动画

在以下场景中,直接禁用动画(让 shouldAnimate() 返回 false):

  • prefersReducedtrue
  • isLowEndtrue 且该动画属于非核心功能
  • 元素处于屏幕外(Off-screen)且永远不会进入视口
  • 纯装饰性动画,对 UX 提升没有任何实际帮助

核心概念

Token 系统

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

运行时 Flag 机制

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

无障碍适配(Accessibility)

优先级顺序(由高到低):

  1. prefers-reduced-motion: reduce —— 禁用所有 Transform 变形,将淡入淡出动画限制在 ≤ 0.2s 内
  2. 低端设备检测 —— 缩短动画时长,移除非核心动画
  3. 设计偏好 —— 其他所有场景

动效必须具备优雅降级(Degrade gracefully)机制。绝不能出现导致布局偏移(Layout shift)或破坏方位感(Orientation)的猝熄现象。

// 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 Safety)

硬规则:initial 的值必须时刻与服务端渲染的输出匹配。

// 错误示例 —— 服务端渲染的是 opacity: 1,但 initial 却写了 0 → 导致水合不匹配(hydration mismatch)
<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 }}
/>

代码示例

端到端完整示例:Tokens + Springs + 无障碍适配 + 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), [])

  // 无障碍适配 —— 开启减弱动效时禁用 transform 变形
  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>
  )
}

边界与非目标(Non-Goals)

本 Skill 不包含 以下内容:

  • UI 组件模式(按钮、Modal、交错动画 stagger)→ 参见 motion-patterns
  • 拖拽、手势、SVG、文本动画、自定义 Hooks → 参见 motion-advanced
  • 不依赖 motion/react 的纯 CSS 动画或 Tailwind animate-* 类名
  • 第三方动画库(GSAP、anime.js 等)
  • 动效设计决策(何时做动画、强调重点是什么)—— 这是设计层面的事,而非代码约束

反模式(Anti-Patterns)

反模式 违反的规则 正确做法
import { motion } from "framer-motion" 规则 1 改用 motion/react
在 SSR 组件上编写 initial={{ opacity: 0 }} 规则 2 添加挂载守护(Mount guard)
遗漏 useReducedMotion 无障碍检查 规则 3 使用 useSafeMotion Hook
animate={{ width: "100%" }} 规则 4 改用 scaleX Transform 变形
内联硬编码 transition={{ duration: 0.4 }} 规则 5 改用 motionTokens.duration.normal
内联硬编码 { stiffness: 300, damping: 30 } 规则 6 改用 springs.snappy
缺失 "use client" 指令 规则 7 在文件顶部加上指令
在模块层级直接使用 navigator.hardwareConcurrency 规则 8 使用 typeof navigator !== "undefined" 包裹护航

关联 Skill

  • motion-patterns —— 使用此处定义的 Token 和弹簧预设来构建按钮、Modal 弹窗、交错动画(stagger)、页面过渡及滚动动画模式。不会重复定义任何数值。
  • motion-advanced —— 使用此处定义的 Token 和弹簧预设来实现拖拽、SVG、文本与手势模式。在此基础之上扩展 useAnimate 动画序列和自定义 Hooks。