动画第16周笔记-CSS transition vs Motion 库 + stagger 入场 + reduced-motion
路线图阶段:第三阶段(Design Engineer 工具箱)第16周 —— 动画(Motion 库,原 framer-motion) 任务:换主色时的颜色平滑过渡(CSS) + 色卡 stagger 刷新入场(Motion) + 尊重 prefers-reduced-motion(a11y) 核心收获:先穷尽便宜工具再上库;动画永远不能让用户等;key-based remounting;一个属性只让一套系统管
⚠️ 守住的 scope:只做一个 Motion 动效(色卡 stagger)。PreviewCard 保持纯 CSS 过渡 —— 两种技术并存本身就是好示范。
0. ⭐ 第一课:CSS transition 和动画库的边界
项目里早就有动画(hover:scale-105 transition)—— 那库解决什么 CSS 做不了的?
CSS transition 做不了(或很痛苦)的四类:
1. enter/exit 元素"出现/消失"的动画(exit 尤其难:DOM 移除是瞬间的,来不及播离场)
2. layout 位置/尺寸因布局变化而移动时平滑过渡(列表重排)
3. 手势驱动 拖拽(drag)、跟手
4. 编排(orchestration) 多元素按顺序/错峰(stagger)入场
一句话边界:“状态 A↔B 的样式渐变”用 CSS transition;“出现/消失/重排/编排”才上库。 方法论:先穷尽便宜工具,再上贵的。 本周颜色过渡用 CSS 一行解决,只有 stagger 编排才装库。 (同 week11 “静态 Tailwind / 动态行内”——先问需求属于哪侧,再选工具。)
1. 好的 UI 动画服务于三件事(设计判断框架)
- 反馈(feedback):告诉用户”操作生效了”(例:已复制)
- 连续性(continuity):帮用户理解”什么变成了什么”(例:换色时颜色流动)
- 引导(orientation):告诉用户”该看哪里”
本周判断:换色跳变突兀 → 加过渡(continuity) ✓;复制反馈文字已够 → 不加(克制) ✓。 知道哪里不加,是 motion design 的一半功力。
2. ⭐ 动效时长:1000ms 的教训
行业基准(Material / Apple HIG 收敛值):
100-200ms 微反馈(hover、按下)
200-400ms 常规过渡(颜色/位置/展开) ← sweet spot
400-700ms 大型转场(整页切换)
1000ms+ 仅叙事性动画(开屏、庆祝)
想给 PreviewCard 用 1000ms —— 被否,理由:
- 动画永远不能让用户等(铁律)。用户换色后要看新配色做判断,1000ms 是在”等动画播完”。
- 好过渡是余光里的顺滑;注意力被动画抓走 = 失败标志。
- 1000ms 是调试用的夸张值(“夸张到不得不注意”),正说明不适合上线。
- 工具类 UI 为高频重复设计:连做 10 遍还不烦,才算合格。(第一次惊艳、第十次拖沓 = 太慢)
- 同一触发源引起的过渡,时长要一致(色卡 300 / preview 1000 会像 bug)。
最终:全部统一 duration-300。
动效调试技巧
先夸张(duration-1000 + 大反差颜色 #C16952→#3B82F6)确认机制生效,再收回合理值。 大反差 + 慢速度 = 确认生效;小变化 + 快速度 = 什么都看不出。
3. CSS 颜色过渡:transition-colors 加在哪
规则:加在”颜色实际发生变化的那个元素”上。 父容器加了不会让子元素跟着平滑——每个元素只管自己的属性。
- 判断”要不要加” = “换色时它自己变不变色”(bg-white 的卡片不变色 → 不加)。
- PreviewCard 六处有 var(—color-*) 的元素各自加
transition-colors duration-300。 - CSS variable 的值变化,transition 能平滑过渡(浏览器过渡最终计算出的颜色值)。
- 同一元素别叠两套 duration(button 已有 hover 的 transition → 统一成一个 duration-300)。
- “每处手写好啰嗦” → 正是组件库要收敛重复样式的原因,week17-18 解决。
4. Motion 库(原 framer-motion,2024 底改名)
npm install motion # NOT framer-motion(旧名,老教程都是它,别照抄)
import { motion } from "motion/react"; // new import path
装完重启 dev server。报
Failed to resolve import= 依赖/路径层问题(没装/装错名/没重启), 排查顺序:npm list <包名> → 拼写 → 重启。resolve 错误 100% 不在组件代码里。
核心 API:motion.xxx + 三件套
<motion.button
initial={{ opacity: 0, scale: 0.92 }} // start state (before appearing)
animate={{ opacity: 1, scale: 1 }} // end state
transition={{ duration: 0.3 }} // how: duration/easing/delay
>
Figma 翻译:initial = 原型起始状态,animate = 结束状态,transition = Smart Animate 的时长曲线。 声明式(declarative):描述”从哪到哪”,库负责补间(tweening)。 motion.button 依然是 button —— a11y 成果(Tab/Enter/focus ring)全保留。
5. ⭐⭐ key-based remounting:让”数据变化”触发入场动画
问题:initial/animate 是挂载(mount)时的入场动画。但换主色时色卡不会重新挂载——React 靠 key 判断”同一个元素”(week3 reconciliation),role 没变就复用实例,动画不重播。
解法:key 里掺入”每次换色都变”的数据:
key={`${role}-${primaryHex}`} // primary color changes → key changes → remount → animation replays
⭐ 为什么用 primaryHex 而不是 timestamp
- key 的语义是”元素身份”。
role+主色= “primary 角色在 #3B82F6 配色下的那张卡”——身份和数据挂钩,读代码即懂。timestamp 是无关的触发器 hack。 - timestamp 还要额外造 state(render 里 Date.now() 会无限重挂载);primaryHex 是现成 state,零成本。
- 主色没变(复制/重渲染)→ key 不变 → 不误触发。
原则:key 用”能代表元素身份的数据”,不用人造触发器。 这是 React 社区的标准手法(key-based remounting)。
6. stagger 错峰:index → delay
// parent: .map() 的第二个参数就是 index
{colorsArray.map(([role, color], index) => (
<Swatch index={index} ... />
// Swatch: transition 里按序递增 delay
transition={{ duration: 0.3, delay: index * 0.05 }}
- 第 n 张延迟 n×50ms → 波浪式入场。SwatchProps 加
index: number。 - 间隔别贪:50ms/张,五张总差 0.25s。间隔大了从”有质感”变”拖沓”(300ms 教训同款)。
7. ⭐ 冲突:一个属性只让一套系统管
animate={{ scale: 1 }} // Motion 管 scale(行内 style 写 transform)
className="hover:scale-105" // Tailwind hover 也管 scale → 抢同一个 transform!
Motion 行内样式优先级高 → 动画播完留下的 scale:1 可能压住 hover:scale-105。
- 测法:动画播完后 hover,还放大吗?
- 若失效 → 把 hover 也交给 Motion:
whileHover={{ scale: 1.05 }},className 里删 hover:scale-105(shadow 留给 CSS)。
原则:一个属性只让一套系统管(transform 归 Motion,颜色/阴影归 CSS)。 和”一个数据一个源头”同一思想,用在动画上。
8. ⭐ prefers-reduced-motion(动画的 a11y 义务)
前庭障碍/晕动症用户会开系统”减少动态效果”——位移/缩放类动画会让他们头晕恶心。尊重它是义务。
import { motion, useReducedMotion } from "motion/react";
const shouldReduceMotion = useReducedMotion(); // true if user prefers reduced motion
<motion.button
initial={shouldReduceMotion ? false : { opacity: 0, scale: 0.92 }}
// initial={false} → skip enter animation entirely, appear at final state
⭐ initial={false} 不是”假”,是哨兵值(sentinel value)
initial的类型本来就是 union:“对象 | false”。false = 库定义的**“跳过入场动画”指令**。- 类比
display: none:none 不是一种显示方式,是”别显示”的指令。 - 模式识别:第三方库的 prop 接受”正常值 或 false/null”时,false/null 通常是”关闭该功能”的开关。读库文档常见。
- 那行三元两分支类型不对称(false vs 对象)看着别扭,但语法就是标准三元,合法且刻意。
范围判断:真正要关的是位移/缩放/视差这类”动”的东西(致晕);颜色渐变温和不致晕,一般不关。只处理 Motion 这边,别过度。
验证:macOS 系统设置 → 辅助功能 → 显示 → 减弱动态效果,切换确认动画跳过/恢复。真测过,不是想当然。
第16周验收
- ⭐ 懂 CSS transition vs 动画库的边界(渐变 vs 出现/消失/编排)
- 懂动画三目的(反馈/连续性/引导),并会判断”哪里不加”
- ⭐ 动效时长基准(200-400ms sweet spot),“动画不能让用户等”、“连做10遍不烦”
- transition-colors 加在”自己变色的元素”上,同触发源时长一致
- 装 Motion(新名,不是 framer-motion),懂 resolve 报错排查
- motion.xxx + initial/animate/transition 三件套
- ⭐⭐ key-based remounting:key 掺 primaryHex 触发重挂载(用数据不用 timestamp)
- stagger:map 的 index → delay: index*0.05
- ⭐ “一个属性一套系统管”(Motion 的 scale vs Tailwind hover:scale)
- ⭐ useReducedMotion + initial={false}(哨兵值模式),真机验证
反复提醒过、需继续巩固的点
- 先穷尽便宜工具(CSS)再上库;别为 CSS 能做的事装库。
- 动效经得起重复:高频操作连做10遍不烦才合格。
- key 用真实数据当身份;render 里生成 timestamp 会无限重挂载。
- 库 prop 里的 false/null 常是”关闭功能”的哨兵值,读文档认得这个模式。