CSS variables 做运行时主题:用户自选色彩 + 实时切换

起因

设计师要"用户能自选主色"——蓝色 / 紫色 / 红色 / 绿色等 5 个选项,
切换立刻全 UI 跟着变。
传统做法:每个主题独立 stylesheet,user 选 → 切换 <link> href。
重 + 慢 + 全 UI 闪一下。

CSS 变量(custom properties)让"颜色变量化",运行时改 root 上的变量值
→ 所有引用立刻更新,无闪烁、无重新 load。

基础

:root {
  --color-primary: #2563eb;
  --color-primary-hover: #1d4ed8;
  --color-text: #1f2937;
  --color-bg: #ffffff;
}

button.primary {
  background: var(--color-primary);
  color: white;
}
button.primary:hover {
  background: var(--color-primary-hover);
}

JS 改变量:

document.documentElement.style.setProperty('--color-primary', '#9333ea')
// 所有用了 var(--color-primary) 的元素瞬间变紫

多主题 preset

:root {
  --color-primary: #2563eb;     /* default 蓝 */
  --color-primary-hover: #1d4ed8;
}

[data-theme="purple"] {
  --color-primary: #9333ea;
  --color-primary-hover: #7e22ce;
}

[data-theme="red"] {
  --color-primary: #dc2626;
  --color-primary-hover: #b91c1c;
}

[data-theme="green"] {
  --color-primary: #16a34a;
  --color-primary-hover: #15803d;
}

切换:

document.documentElement.setAttribute('data-theme', 'purple')

零延迟切换。

派生:HSL + alpha 灵活

把 primary 拆 H/S/L 分量存:

:root {
  --color-primary-h: 220;
  --color-primary-s: 90%;
  --color-primary-l: 56%;
}

button {
  background: hsl(var(--color-primary-h) var(--color-primary-s) var(--color-primary-l));
  border: 1px solid hsl(var(--color-primary-h) var(--color-primary-s) var(--color-primary-l) / 0.3);
}

button:hover {
  /* hover 加深:l - 10% */
  background: hsl(var(--color-primary-h) var(--color-primary-s) calc(var(--color-primary-l) - 10%));
}

只改 hue 就换主题(不用每个 shade 独立定义):

root.style.setProperty('--color-primary-h', '270')   // 紫
root.style.setProperty('--color-primary-h', '0')      // 红
root.style.setProperty('--color-primary-h', '140')    // 绿

省定义。

用 color-mix(CSS Color 5)

button {
  background: var(--color-primary);
}
button:hover {
  background: color-mix(in srgb, var(--color-primary) 80%, black);
}

color-mix 让浏览器自动算"加深 20%",不需要 hover-specific 变量。
Chrome 111+ / Safari 16.2+ / Firefox 113+ 支持。

用户自定义主色:color picker

function ThemeCustomizer() {
  const [hue, setHue] = useState(220)

  useEffect(() => {
    document.documentElement.style.setProperty('--color-primary-h', hue.toString())
    localStorage.setItem('theme-hue', hue.toString())
  }, [hue])

  return (
    <div>
      <label>主色 hue</label>
      <input type="range" min="0" max="360"
             value={hue} onChange={e => setHue(+e.target.value)} />
      <div style={{
        background: `hsl(${hue} 90% 56%)`,
        width: 100, height: 30
      }} />
    </div>
  )
}

slider 拖动 → 全站颜色实时变。
保存到 localStorage → 下次访问还原。

CSS Color 5 高级

:root {
  --color-primary: oklch(60% 0.2 250);
}
.text-on-primary {
  /* 自动算对比色 */
  color: oklch(from var(--color-primary) calc(l > 50% ? 0 : 100%) 0 0);
}

oklch 色彩空间感知更线性,主题派生更自然。
relative color syntax 让 "从 primary 派生 text" 一行写完。

Browser 支持 2024 大多 evergreen 已经 OK。

暗色模式

:root {
  --color-bg: white;
  --color-text: #111;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #0d1117;
    --color-text: #e6edf3;
  }
}

/* 手动覆盖 */
[data-mode="dark"] {
  --color-bg: #0d1117;
  --color-text: #e6edf3;
}
[data-mode="light"] {
  --color-bg: white;
  --color-text: #111;
}

<html data-mode="dark"> 强制;不设 attr 跟系统。

防止 FOUC

如果初始化 theme 在 React 之后 → 闪一下默认主题再切。
解决:inline script 在 head 顶部立刻设:

<head>
  <script>
    (function() {
      const saved = localStorage.getItem('theme')
      if (saved) document.documentElement.setAttribute('data-theme', saved)
    })()
  </script>
  <link rel="stylesheet" href="/main.css">
</head>

页面渲染前 theme attribute 已经在 → 没有 FOUC。

CSS 框架配合

Tailwind 用 CSS variables 已经标准化(v3.3+):

// tailwind.config.ts
theme: {
  extend: {
    colors: {
      primary: 'hsl(var(--color-primary-h) var(--color-primary-s) var(--color-primary-l) / <alpha-value>)',
    },
  },
}
<button class="bg-primary text-primary-foreground">Click</button>
<!-- bg-primary 自动用 CSS variable -->

切换 variable → 所有 Tailwind class 立刻变。

性能

CSS variable 修改是浏览器层面 reflow / repaint,非常快。
全站千个 element 用同一 variable 切换 < 1 frame(16ms)。

对比"重新 load stylesheet" 切换:
- 重新解析 CSS(10-100ms)
- 全 DOM repaint
- 偶尔 flash

variable 切换胜出。

与 CSS-in-JS 对比

// emotion / styled-components 风格
const StyledButton = styled.button`
  background: ${props => props.theme.primary};
`

<ThemeProvider theme={{ primary: 'blue' }}>
  <StyledButton>Click</StyledButton>
</ThemeProvider>

CSS-in-JS theming:

  • 优点:JS 完整控制 + 静态 type
  • 缺点:bundle 大 + 运行时开销 + SSR 复杂

CSS variable:

  • 优点:原生 + 零运行时 + 兼容任何框架
  • 缺点:无类型 + JS 改时要 string

我个人 2024 后偏向 CSS variable + Tailwind / shadcn 的方案,
CSS-in-JS 用得少了。

限制 / 注意

1. variable 不能用在 @media 条件本身

:root { --break: 768px; }

/* ❌ 不行 */
@media (min-width: var(--break)) { ... }

@media 查询的值必须是 literal。

2. variable 不能改 @keyframes

@keyframes slide {
  from { left: 0; }
  to { left: var(--target); }    /* 这能用 */
}

keyframes 内部用 variable OK;但改 variable 后正在跑的动画不会重新算。

3. 旧 IE / 老 Safari 不支持

IE 11 全不支持;Safari 9.1+ 支持但有 bug。
2024 这些已经不需考虑。

我们的主题系统

:root {
  /* 基础 hue 用户可调 */
  --hue: 220;

  /* 派生:primary 系列 */
  --color-primary-50:  hsl(var(--hue) 90% 96%);
  --color-primary-100: hsl(var(--hue) 90% 90%);
  --color-primary-500: hsl(var(--hue) 90% 56%);
  --color-primary-700: hsl(var(--hue) 90% 36%);

  /* 派生:text on primary(自动选黑/白) */
  --text-on-primary: white;

  /* 中性色 */
  --color-gray-50:  oklch(98% 0 0);
  --color-gray-500: oklch(50% 0 0);
  --color-gray-900: oklch(20% 0 0);
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-primary-500: hsl(var(--hue) 80% 65%);   /* 暗色下提亮 */
  }
}

用户改 --hue → 整套主题色协调地变。
设计师 once 调好 hue 与各 shade 关系,用户只动 hue 不破坏视觉一致。

踩过的坑

  1. var(--x) 没 fallback → variable 没定义时整个声明无效:
    css color: var(--missing); /* 整个 color 不生效 */ color: var(--missing, black); /* fallback 到 black */

  2. JS setProperty 大小写敏感:variable 名是 --Color-Primary
    setProperty('--color-primary', ...) 设错变量。

  3. shadow DOM 隔离:custom element 内部不继承 :root 变量。
    需要在 shadow boundary 显式 forward。

  4. calc 不能跨单位calc(var(--x) * 2px) 错(var 已经带单位)。
    存 unitless number + 在 calc 加 unit。

  5. 打印模式@media print 没单独考虑主题 variable → 打印出来
    是 default 颜色。专门 @media print 覆盖。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。