第 6 章:CSS 多色调系统

如果你只读 sag-icons 的一章,应该读这一章。

CSS 多色调系统是 sag-icons 的灵魂。它实现了 sag-icons 的标志性特性——"加个 is-active 类就变彩色"。但背后其实是一套通用的 CSS 主题系统设计模式,可以套到任何 UI 库里。

读完这一章你会知道:

  • CSS 变量 + data-* 属性怎么组合做主题
  • is-active 状态切换的完整链路
  • 运行时注入 CSS 的去重机制
  • 怎么给用户提供"自己引 CSS"的入口

6.1 核心设计:CSS 变量 + data-tone

sag-icons 的 CSS 全文:

src/css/svg-icon.ts
export const svgIconCss = `:root {
  --sag-icon-blue: #3b82f6;
  --sag-icon-cyan: #06b6d4;
  --sag-icon-green: #22c55e;
  --sag-icon-amber: #f59e0b;
  --sag-icon-white: #ffffff;

  /* 默认灰色(非激活态) */
  --sag-icon-default: #9ca3af;
}

sag-icon {
  display: inline-block;
  vertical-align: middle;
  width: 1.25rem;
  height: 1.25rem;
  color: var(--sag-icon-default);
  flex-shrink: 0;
  transition: color 160ms ease, filter 160ms ease;
}

sag-icon > svg {
  width: 100%;
  height: 100%;
  display: block;
}

sag-icon [data-tone] {
  color: currentColor;
  opacity: 0.8;
  transition: color 180ms ease, opacity 180ms ease;
}

/* 激活态:彩色 */
sag-icon.is-active {
  filter: saturate(1.08);
}

sag-icon.is-active [data-tone] {
  opacity: 1;
}

sag-icon.is-active [data-tone="blue"] {
  color: var(--sag-icon-blue);
}

sag-icon.is-active [data-tone="cyan"] {
  color: var(--sag-icon-cyan);
}

sag-icon.is-active [data-tone="green"] {
  color: var(--sag-icon-green);
}

sag-icon.is-active [data-tone="amber"] {
  color: var(--sag-icon-amber);
}

sag-icon.is-active [data-tone="white"] {
  color: var(--sag-icon-white);
}`;

对应文件:src/css/svg-icon.ts

我们一段段拆。


6.2 第一层:CSS 变量定义

:root {
  --sag-icon-blue: #3b82f6;
  --sag-icon-cyan: #06b6d4;
  --sag-icon-green: #22c55e;
  --sag-icon-amber: #f59e0b;
  --sag-icon-white: #ffffff;

  --sag-icon-default: #9ca3af;
}

把所有颜色集中到 :root。用户换肤只要覆盖 6 个变量:

:root {
  --sag-icon-blue: #5856eb;   /* 自定义品牌色 */
  --sag-icon-default: #d4d4d8;
}

不需要 props、不需要 wrapper、CSS 即真相。

为什么用--sag-icon- 前缀

所有变量都加 sag-icon- 前缀,避免和用户变量冲突。这是 CSS 变量命名的常识:

  • 库名 / 包名作为前缀
  • 颜色语义名(blue/green/amber)作为后缀

6.3 第二层:sag-icon 默认态

sag-icon {
  display: inline-block;
  vertical-align: middle;
  width: 1.25rem;
  height: 1.25rem;
  color: var(--sag-icon-default);
  flex-shrink: 0;
  transition: color 160ms ease, filter 160ms ease;
}

sag-icon > svg {
  width: 100%;
  height: 100%;
  display: block;
}

关键点:

  • color: var(--sag-icon-default) — 默认灰色。color 是 CSS 里一个特殊属性:它能被 currentColor 关键字继承
  • vertical-align: middle — 让图标和文字基线对齐
  • flex-shrink: 0 — 在 flex 容器里不被压缩
  • transition — 颜色切换带过渡

注意:所有 path 用 fill="currentColor"(上一章讲过),所以 color 变化会传到 SVG 内部。这就是 CSS 主题能起作用的关键。


6.4 第三层:data-tone 的通用处理

sag-icon [data-tone] {
  color: currentColor;
  opacity: 0.8;
  transition: color 180ms ease, opacity 180ms ease;
}

这一段是关键中的关键。

默认态下,所有带 data-tone 的 path:

  • color: currentColor — 继承 <sag-icon> 的 color(默认灰色)
  • opacity: 0.8 — 比"白板"状态略弱一点

为什么是 0.8?给用户一个"激活前是次要状态、激活后是主状态"的层次感。如果默认就是 1.0,激活态没什么视觉变化。


6.5 第四层:is-active 激活态

sag-icon.is-active {
  filter: saturate(1.08);
}

sag-icon.is-active [data-tone] {
  opacity: 1;
}

sag-icon.is-active [data-tone="blue"] {
  color: var(--sag-icon-blue);
}
/* ...其他色... */

加 is-active 类后:

  1. 整个图标 filter: saturate(1.08) — 颜色饱和度 +8%(微妙的"被点亮"感)
  2. 所有 data-tone 元素 opacity: 1 — 去掉默认的"次要"感
  3. 每个具体 data-tone 应用对应的 CSS 变量

这就是"加个类就彩色"的全部秘密——一组 CSS 规则。

为什么用[data-tone="blue"] 而不是.blue

data-tone 是 HTML5 标准属性,对自定义元素友好:

  • HTML 自带,不需要 BEM 命名空间
  • 选择器 [data-tone="blue"] 性能 OK(现代浏览器高度优化)
  • SVG path 里也能用(<path data-tone="blue"> 是合法 SVG)

CSS 优先级

注意选择器的特异性:

sag-icon [data-tone="blue"] ← 默认 0,1,1 sag-icon.is-active [data-tone="blue"] ← 激活态 0,2,1

激活态选择器多一个 .is-active 类,特异性更高。所以 is-active 类加上后样式自动生效,不需要 !important。


6.6 视觉链路:颜色切换的完整流程

一个完整的"从默认到激活"颜色切换,经历了 5 个步骤:

1. 用户加类:<sag-icon name="workbench" class="is-active" /> 2. CSS 选择器匹配:sag-icon.is-active [data-tone="blue"] 命中 3. color: var(--sag-icon-blue) 把 blue 元素的 color 切到 #3b82f6 4. <path data-tone="blue" fill="currentColor"> 用 currentColor 拿值 5. 浏览器过渡:transition: color 180ms ease 让切换平滑

每一步都是纯 CSS,没有任何 JS 介入。这就是 sag-icons"加个类就完事"的本质。


6.7 运行时注入 CSS

sag-icons 在 src/index.ts 里自动把 CSS 字符串塞进 <head>:

src/index.ts
import { svgIconCss } from "./css/svg-icon";

const STYLE_ID = "sag-icons-styles";
function injectStyles(): void {
  if (typeof document === "undefined") return;
  if (document.getElementById(STYLE_ID)) return;

  const styleEl = document.createElement("style");
  styleEl.id = STYLE_ID;
  styleEl.textContent = svgIconCss;
  document.head.appendChild(styleEl);
}

injectStyles();

对应文件:src/index.ts

几个关键点:

SSR 安全

if (typeof document === "undefined") return;

Node.js / SSR 环境(Next.js / Nuxt / SvelteKit)下 document 不存在。直接操作会抛错。

去重

if (document.getElementById(STYLE_ID)) return;

每次 import "@jujiuyey/sag-icons" 都会跑 injectStyles()。但同一页面 import 多次只应该注入一次。

检查 STYLE_ID = "sag-icons-styles" 的 <style> 元素是否存在:

  • 存在 → 已经注入了,直接返回
  • 不存在 → 创建并注入

这是幂等性的具体实现。

为什么用id 而不是 querySelectorAll

理论上可以用 document.querySelectorAll('style[data-sag-icons]') 之类的检查。但 id 是最快、最简单的:

if (document.getElementById("sag-icons-styles")) return;

O(1) 查询,浏览器内部优化好。


6.8 单独 CSS 入口

sag-icons 的 CSS 默认运行时注入——用户 import "@jujiuyey/sag-icons" 就完事。

但有些场景用户想自己控制 CSS 引入。比如:

  • 公司项目要求所有样式走 PostCSS 处理
  • 想用 <link rel="stylesheet"> 而不是 <style> 注入
  • 想在文档 <head> 顶部放 CSS 而不是在 JS 执行后注入

sag-icons 提供了原始 CSS 文件:

package.json (假设)
"exports": {
  ".": { "...": "..." },
  "./css/svg-icon.css": "./dist/svg-icon.css"
}

用户可以:

import "@jujiuyey/sag-icons";           // 注册 Web Component
import "@jujiuyey/sag-icons/css/svg-icon.css";  // 单独引 CSS

第一行只注册 <sag-icon>,不注入 CSS(因为 injectStyles 还是会跑——但 STYLE_ID 没冲突也无害)。

注:sag-icons 当前 vite.config.ts 没配置 CSS 产物。这是已知的小坑,第 8 章会讲怎么补。


6.9 为什么不用 Shadow DOM

Web Components 的三大特性:Custom Elements + Shadow DOM + HTML Templates。

sag-icons 故意没用 Shadow DOM。原因:

/* 这段 CSS 在 Shadow DOM 外面 */
:root {
  --sag-icon-blue: #3b82f6;
}

/* 内部 path 怎么拿到这个变量? */
sag-icon.is-active [data-tone="blue"] {
  color: var(--sag-icon-blue);  /* ← 拿不到 */
}

Shadow DOM 的核心是样式隔离——外部 CSS 进不去,内部 CSS 出不来。

但 sag-icons 的 CSS 主题系统依赖外部变量穿透:

/* 用户在外部写 */
:root {
  --sag-icon-blue: #5856eb;  /* 自定义品牌色 */
}

/* sag-icon 内部的 path 用这个变量 */
sag-icon.is-active [data-tone="blue"] {
  color: var(--sag-icon-blue);
}

这必须没有 Shadow DOM。CSS 变量通过 DOM 树继承穿透进去。

不用 Shadow DOM 的代价

  • 全局样式污染:用户写的 sag-icon { ... } 会影响 sag-icons(反过来也是)
  • sag-icons 内部样式必须用 sag-icon [data-tone] 这种足够具体的选择器

但 sag-icons 的 CSS 只有 ~40 行,污染风险可控。


6.10 怎么扩展:hover / focus / disabled

is-active 不是唯一的扩展点。CSS 多色调系统可以横向扩展:

/* hover 态 */
sag-icon:hover:not(.is-active) [data-tone] {
  opacity: 0.9;
  color: var(--sag-icon-cyan);  /* 给个轻提示 */
}

/* focus 态 */
sag-icon:focus-visible {
  outline: 2px solid var(--sag-icon-blue);
  outline-offset: 2px;
}

/* disabled 态 */
sag-icon[disabled],
sag-icon.is-disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

sag-icons 本身没实现这些,但 CSS 系统设计好了,加这些就是几行 CSS。

这就是 CSS 变量 + 属性驱动的扩展性。


6.11 这一章的产出

到这里你应该能:

  • 解释 sag-icons 的 CSS 主题系统怎么工作
  • 解释 is-active 类激活态的 CSS 选择器链路
  • 解释 CSS 运行时注入的去重机制
  • 解释为什么不用 Shadow DOM

下一章讲类型系统——怎么让 <sag-icon name="..." /> 有 TS 自动补全,怎么做按需导入的子路径。


对应文件

内容路径
CSS 字符串src/css/svg-icon.ts
注入逻辑src/index.ts:10-23
图标 data-tone 标记src/data/icons/<category>/<name>.ts