鑫EN

基于原子 CSS 的图标动效方案

时间
2026.05.31

一、背景

团队在升级平台框架时,为侧边栏图标增加了 Hover 动效。框架覆盖约 200 个云产品,共有 2000 多个图标。

如果逐个使用 After Effects 制作,成本较高,也难以保证一致性。

因此,我们需要一套可以批量生成、统一约束并支持人工调整的方案。AI 可以参与生成,但它需要建立在稳定的动效系统之上。

二、技术选型

首先比较三种常见的图标动效方案:

方案优点缺点
GIF兼容性好,所见即所得文件大、分辨率固定、深色模式难以适配、不能交互控制
Lottie动画丰富,支持复杂路径需要 JSON 文件 + lottie-web(~60KB),与 CSS 体系割裂,存在渲染开销
CSS矢量无损、体积小、无 JS 运行时、与 Tailwind 生态融合复杂动画的表达能力有限

侧边栏图标通常只需要位移、缩放、旋转和少量 3D 变换,CSS 足以覆盖大部分效果。Lottie 需要为图标加载 JSON 和运行时,应用到 2000 个图标时成本较高;GIF 也难以适配深色模式。因此,最终选择 CSS。

三、CSS 原子动效

3.1 原子化

选择 CSS 后,数量问题仍然存在。为 2000 个图标逐个编写 Keyframes 和 Animation,并不会减少工作量。

Tailwind CSS 提供了一个可用的模型:把动效拆成较小的单元,再由设计师组合。界面可以用 flex、p-4、text-sm 构建,动效也可以用 scale-75、rotate-12 一类原子类描述。

这种方式也适合 AI。与从零生成 Keyframes 相比,从有限的动效单元中选择和组合更稳定,也更容易控制输出的一致性。

基于这个方向,我采用了 Tailwind v3 的原子动效库 tailwindcss-motion 作为起点。

tailwindcss-motion 已经定义了较完整的动效分类和预设值。但它基于 Tailwind v3 的 JS 插件架构(matchUtilities + addBase),不能直接用于 Tailwind v4 的纯 CSS 体系。我使用 Claude Code 将其改写为 Tailwind v4 的 @utility 与 CSS Variable 实现,并针对 SVG 图标做了裁剪和扩展。

新增:

  • 描边(draw):tailwindcss-motion 没有的动画类型。通过 stroke-dashoffset 实现线条描绘生长,要求 SVG 元素设置 pathLength="1"。为此扩展了 3 个槽位(draw-in/out/loop)。

描边动画中,stroke-dasharray 设为 1.1,而不是精确的 1;stroke-dashoffset 同时设置 0.05 的初始偏移。当 Dasharray 等于路径长度时,浏览器在边界值附近的亚像素渲染可能产生轻微闪烁。让路径略微超出边界,可以避开这个临界值。

  • 摇晃(shake):在 keyframes 中内置多级衰减振荡(振幅按 -0.5、0.25、-0.1、0 逐级衰减),复用 rotate 槽位,不增加新的 CSS 变量。
  • 反向动画:支持负号前缀语法,如 -motion-translate-x-in-50(反向平移)、-motion-draw-in-100(从路径终点反向描绘)。对 AI 生成和人工手写都更直观。
  • 触发机制:tailwindcss-motion 的动画在元素挂载时自动播放。本方案增加了 data-motion-trigger 属性系统,支持 hover 和 click 触发,未触发时通过 animation-name: none !important 完全抑制动画,不依赖 JS 事件。
  • SVG transform-origin 修复:SVG 元素默认 transform-origin: 0 0(左上角),HTML 元素默认 50% 50%(中心)。通过 [data-motion-icon] 作用域自动设置 transform-box: fill-box,确保旋转和缩放围绕元素自身中心。

移除:

  • Preset 预设系统:tailwindcss-motion 有 30+ 个预组合的动画预设(motion-preset-fade、motion-preset-slide 等)。在本方案中这个职责交给了 AI——由 AI 根据图标语义从原子零件库组合方案,而不是人工从预设中选择。
  • Filter、text-color、background-color 动画:图标动效场景中用不到,直接移除,减少槽位数量和 CSS 体积。
  • Loop 默认从 infinite 改为 1:控制台图标的循环动效通常只需有限次数(如齿轮旋转一两圈后停止),无限循环需要显式添加 motion-loop-infinite。

调整:

  • 默认时长从 700ms 降到 300ms,更适合微交互节奏
  • 补充了基于 linear() 的弹簧缓动(spring-smooth/snappy/bouncy/bouncier/bounciest),来自 kvin.me/css-springs
  • 所有 @keyframes 统一包裹在 @media (prefers-reduced-motion: no-preference) 中(tailwindcss-motion 只包裹了 transform 类,color/opacity 类未包裹)

tailwindcss-motion 是一个通用 Web 动效库。本项目保留了它的槽位架构,并针对 SVG 图标场景进行调整。

项目支持以下动效:

动效入场出场循环说明
位移从上下左右飞入向上下左右飞出上下左右浮动支持反向移动
缩放从小到大出现从大到小消失心跳、呼吸效果支持单轴缩放
旋转旋转进入旋转退出持续旋转支持反向旋转
摇晃晃入晃出持续晃动阻尼振荡效果
淡入淡出渐显渐隐闪烁—
描边生长线条描绘进入线条擦除线条呼吸SVG 路径动画

系统包含 22 种缓动曲线,覆盖标准 Easing、回弹、弹簧和弹跳效果。默认时长为 300ms,并支持配置延迟、循环次数和变换原点。

3.2 tailwindcss-motion 的动画槽位方案

tailwindcss-motion 的槽位设计解决了多个动画工具类相互覆盖的问题。

给一个元素写两个动画,原生 CSS 可以轻松实现:

.element {
  animation:
    scale-in 0.3s,
    rotate-in 0.3s;
}

但通过工具类组合时(animate-scale-in animate-rotate-in),后一个 animation 声明会覆盖前一个,而不是追加。

进场动画与循环动画的组合更复杂。一个元素可能先从左侧进入(in),再在原地持续微动(loop)。前者只执行一次,后者持续执行。由于 animation 属性会被整体替换,两个独立类名不能直接完成这种组合。

3.3 动画槽位

它的做法是预先声明所有槽位。每个工具类只向对应槽位写入动画值,其余槽位保持 none。本质上,这是用 CSS Variable 的 Fallback 机制实现多路复用:

阶段槽位属性
enter(进场)1–6scale-in, translate-in, rotate-in, opacity-in, bg-color-in, draw-in
exit(出场)7–12scale-out, translate-out, rotate-out, opacity-out, bg-color-out, draw-out
loop(循环)13–18scale-loop, translate-loop, rotate-loop, opacity-loop, bg-color-loop, draw-loop

6 属性 × 3 阶段 = 18 个槽位。概念如下:

@utility motion-translate-x-in-* {
  --motion-translate-in-animation: /* 实际的动画值 */;
  animation:
    /* slot 1-6 (enter) */
    var(--motion-scale-in-animation),
    /* → none */ var(--motion-translate-in-animation); /* → 实际动画 */
  /* ... 其余 16 个槽位同理 */
}

实际代码(Tailwind v4 @utility):

@utility motion-translate-x-in-* {
  --motion-origin-translate-x: --value(--motion-translate-*, [percentage], [length]);
  --motion-translate-in-animation: motion-translate-in
    calc(
      var(--motion-translate-duration, var(--motion-duration)) *
        var(--motion-perceptual-duration-multiplier)
    )
    var(--motion-translate-timing, var(--motion-timing))
    var(--motion-translate-delay, var(--motion-delay)) both;

  animation:
    var(--motion-scale-in-animation), var(--motion-translate-in-animation),
    var(--motion-rotate-in-animation), var(--motion-opacity-in-animation),
    var(--motion-background-color-in-animation), var(--motion-draw-in-animation),
    var(--motion-scale-out-animation), var(--motion-translate-out-animation),
    var(--motion-rotate-out-animation), var(--motion-opacity-out-animation),
    var(--motion-background-color-out-animation), var(--motion-draw-out-animation),
    var(--motion-scale-loop-animation), var(--motion-translate-loop-animation),
    var(--motion-rotate-loop-animation), var(--motion-opacity-loop-animation),
    var(--motion-background-color-loop-animation), var(--motion-draw-loop-animation);
}

所有工具类共享同一份 18 槽 animation 声明。组合 motion-scale-in-75 motion-translate-x-loop-25 时,每个类只写入自己的槽位,因此不会相互覆盖。

Loop 槽位使用 animation-composition: accumulate,使循环动画叠加在进场动画的最终状态上。元素可以先完成进场,再从当前位置继续循环。

3.4 任意值

预设值不够时,可以用方括号语法指定任意 CSS 值:

<g class="motion-translate-x-in-[12px] motion-rotate-loop-[30deg]">
  <path d="..." pathLength="1" />
</g>

Tailwind v4 的 --value() 从类名里提取值,直接映射到 CSS 变量。

3.5 无障碍

所有 Keyframes 都包裹在 @media (prefers-reduced-motion: no-preference) 中。用户在系统中减少动态效果后,动画不会执行。

3.6 触发方式

侧边栏动效主要由 Hover 触发。触发逻辑尽量保留在 CSS 中,通过 data-motion-trigger 属性和 CSS 属性选择器控制动画状态:

[data-motion-trigger="hover"]:hover {
  --motion-trigger: running;
}

将 motion-trigger 放在外部容器上,Hover 侧边栏的 List Item 时,即可触发内部图标。

在 React 中,motion-react 提供一个简单的 Hook:

import { useHoverMotionTrigger } from "motion-react/hooks";

function MyIcon() {
  const hoverProps = useHoverMotionTrigger<HTMLDivElement>();
  return (
    <div {...hoverProps}>
      <SettingsIcon />
    </div>
  );
}

useHoverMotionTrigger 返回一个 ref 和 data-motion-trigger="hover" 属性,其余部分由 CSS 处理。它不监听 mouseenter 或 mouseleave,也不需要切换 State 或编写 JS 动画逻辑。

动画的播放与暂停由浏览器的 CSS 引擎处理,不会触发 React 重渲染,也不需要 JS 计算进度。对于包含 2000 多个图标的系统,这可以减少运行时成本。

四、项目结构

项目采用 Monorepo,包含三个部分:

css-motion-system/
├── apps/studio/                # Next.js Web 应用
├── packages/
│   ├── motion/             # 纯 CSS 动效库
│   └── motion-react/       # React 图标组件

依赖关系为 studio → motion-react → motion,单向无循环。

motion 不需要构建,CSS 可以直接发布。motion-react 通过 tsup 打包 ESM、CJS 和类型定义。studio 是供设计师制作图标动效的本地 Web 应用。

五、Web UI 工作流

motion-web-ui

Studio 将图标动效流程拆成四步。AI 负责生成初始结果,设计师负责判断和调整。

Step 1:上传 SVG。通过拖拽导入文件。

Step 2:SVGO 优化与语义分组。SVGO 负责移除冗余节点、统一属性顺序,并为 Path 添加 pathLength="1"。随后使用 Gemini 3.1 Flash 分析 SVG 源码和渲染图,生成分组建议。例如,齿轮图标可以被识别为“外围齿牙”和“中心圆孔”两个语义组:

<svg viewBox="0 0 24 24">
  <g data-group="outer teeth">
    <path d="..." />
  </g>
  <g data-group="center hole">
    <circle ... />
  </g>
</svg>

编辑器支持叠图比较,用不同颜色显示分组,也允许手动调整结构。

Step 3:生成动效方案。设计师可以选择动画类型、时长、缓动曲线和循环次数,并实时预览。

motion-web-ui-2

AI 也可以根据图标语义生成三套方案。以齿轮图标为例:

  • 方案 A:外齿牙慢转(loop),中心孔淡入(in)
  • 方案 B:整图标从左边滑入(translate-in),旋转 90°(rotate-in)
  • 方案 C:描边生长(draw-in),配上轻微缩放弹跳(scale-in + spring-bouncy)

选定方案后,仍可以调整动画类型、时长、缓动曲线和循环次数。

Step 4:导出。支持两种格式:

  • React TSX 组件,带类型,size/strokeWidth props
  • 独立 SVG,CSS 内嵌,非 React 场景也能用

TSX 可以直接写入 motion-react 的源码目录,构建后发布到 npm。

六、Skill

除 Web UI 外,完整流程也被封装为 Skill。用户可以通过自然语言完成 SVG 输入、SVGO 优化、AI 分组、方案生成、选择与调整、TSX 生成和组件写入。

在终端中输入 SVG 代码后,也可以使用 Sub-agents 并行处理多个图标。

Skill 适合批量生成,GUI 适合调整单个图标,两种工作流可以配合使用。

七、总结

这套方案包含四个部分:

  • 基于 tailwindcss-motion 的槽位架构,为 SVG 图标补充和调整原子动效;
  • motion-react 库,提供 React 图标组件与 Hook;
  • Studio Web UI,支持 AI 生成和人工调整;
  • Skill,通过自然语言完成相同流程。

目前,这套系统已用于公司官网和部分产品侧边栏。熟练设计师可以在一天内完成约 15 个侧边栏图标的动效,实际制作效率约为原流程的 20 倍。

八、致谢

感谢 romboHQ 的 tailwindcss-motion,以及 Tailwind CSS 提供的原子化思路。