返回首页

CSS 变量与主题切换:做一套可维护的暗色模式

CSS 自定义属性不只是少写几个颜色值,它能在运行时被 JS 读写、随作用域级联。这篇从 CSS 变量的基础讲到一套完整的明暗主题方案,包含防止刷新闪烁、跟随系统、平滑过渡等实战细节。

29 约 4 分钟 · 4775 字 前端

很多人第一次用 CSS 变量,只是为了"把颜色统一管理一下"。但 CSS 自定义属性真正强大的地方在于:它是运行时生效、能被级联覆盖、还能被 JS 读写的。这让它成为做主题切换(尤其是暗色模式)的天然工具。

这篇从基础讲到一套能直接用的明暗主题方案。

CSS 变量基础

CSS 自定义属性以 -- 开头,用 var() 读取:

:root {
  --brand: #2563eb;
  --space: 16px;
}

.button {
  background: var(--brand);
  padding: var(--space);
}

var() 可以带兜底值,当变量没定义时使用:

.card {
  color: var(--text-color, #333); /* --text-color 没定义就用 #333 */
}

它和 Sass 变量有本质区别:

CSS 变量 Sass/Less 变量
生效时机 运行时 编译时
能否被 JS 读写 不能
是否随 DOM 级联
能否在媒体查询里切换 静态

正是"运行时 + 可被 JS 修改 + 随级联"这三点,让 CSS 变量适合做主题。

关键特性:级联与作用域

CSS 变量遵循级联规则:定义在某个元素上,该元素及其后代都能用,而且后代可以覆盖

:root {
  --text: #111;
}

.dark-panel {
  --text: #eee; /* 只在这个面板内部覆盖 */
}

p {
  color: var(--text);
}

.dark-panel 里的 <p> 是浅色字,外面的 <p> 是深色字。同一份 color: var(--text) 规则,因为变量值随作用域不同而不同。

这正是主题切换的核心机制:我们只需要在顶层切换一组变量的值,所有用到这些变量的元素会自动更新

设计主题:语义化变量分层

做主题最常见的错误是直接把"颜色名"当变量,比如 --blue--gray-100。一旦暗色模式下蓝色要变,语义就乱了。

更好的做法是两层变量:

  1. 底层调色板(palette):描述具体颜色,不带语义。
  2. 语义层(semantic):描述用途,引用调色板。
:root {
  /* 第一层:调色板 */
  --blue-500: #3b82f6;
  --gray-50: #f9fafb;
  --gray-900: #111827;
  --white: #ffffff;

  /* 第二层:语义,组件只用这一层 */
  --color-bg: var(--white);
  --color-text: var(--gray-900);
  --color-primary: var(--blue-500);
  --color-border: var(--gray-50);
}

组件里只引用语义层:

body {
  background: var(--color-bg);
  color: var(--color-text);
}

切换主题时,只改语义层的指向即可。

切换暗色:用 data 属性

<html> 上挂一个属性(比如 data-theme),用属性选择器覆盖语义变量:

:root {
  --color-bg: #ffffff;
  --color-text: #111827;
  --color-primary: #2563eb;
}

:root[data-theme='dark'] {
  --color-bg: #0f172a;
  --color-text: #e2e8f0;
  --color-primary: #60a5fa;
}

JS 切换只需要一行:

function setTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme)
  localStorage.setItem('theme', theme)
}

// 切换
setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark')

页面上所有用 var(--color-bg) / var(--color-text) 的地方,瞬间全部切换。不需要逐个改 class。

为什么用 data-theme 而不是 class?两者都行。Tailwind 默认用 class="dark",原生项目里 data-theme 更语义化、也方便扩展多套主题(data-theme="sepia")。

跟随系统偏好

操作系统有"浅色/深色"偏好,CSS 用媒体查询 prefers-color-scheme 读取。我们希望:用户没手动选过就跟随系统,选过就尊重用户选择

CSS 里先写一套"自动跟随"的默认:

/* 用户没有手动指定(没有 data-theme)时,跟随系统 */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    --color-bg: #0f172a;
    --color-text: #e2e8f0;
  }
}

/* 用户手动指定时,优先级更高,覆盖系统 */
:root[data-theme='dark'] { --color-bg: #0f172a; --color-text: #e2e8f0; }
:root[data-theme='light'] { --color-bg: #ffffff; --color-text: #111827; }

JS 初始化时的判断逻辑:

function initTheme() {
  const saved = localStorage.getItem('theme')
  if (saved) {
    // 用户手动选过
    document.documentElement.setAttribute('data-theme', saved)
  }
  // 没选过就不设 data-theme,交给 prefers-color-scheme 媒体查询自动处理
}

关键坑:防止刷新闪烁(FOUC)

这是暗色模式最经典的坑:页面加载时先显示浅色,JS 执行后才切成深色,白屏闪一下

原因是读取 localStorage 设置主题的 JS 跑得太晚(在框架 hydration 之后)。解决办法是把这段逻辑做成阻塞式内联脚本,放在 <head> 最前面,在页面渲染前就把 data-theme 设好:

<head>
  <script>
    // 必须是同步内联,在任何样式/内容渲染前执行
    (function () {
      var saved = localStorage.getItem('theme')
      var theme = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
      document.documentElement.setAttribute('data-theme', theme)
    })()
  </script>
  <!-- 后面才是 CSS、其它资源 -->
</head>

在 SSR 框架(Nuxt / Next)里,这段脚本要通过框架提供的方式注入到 HTML 头部,确保它先于内容执行。这是消除主题闪烁的标准做法。

平滑过渡,但别误伤

切换主题时给颜色加过渡,体验更好:

body {
  transition: background-color 0.2s ease, color 0.2s ease;
}

但要只过渡颜色相关属性,别用 transition: all——否则页面里所有元素的尺寸、位置变化都会跟着动,反而显得卡。另外,首次加载时也可能因为过渡导致一闪,可以在初始化完成后再加上过渡 class。

JS 读写 CSS 变量

CSS 变量能被 JS 动态读写,这是它比预处理器变量强的地方,适合做主题色自定义、跟随滚动的动态效果等:

const root = document.documentElement

// 读
const primary = getComputedStyle(root).getPropertyValue('--color-primary').trim()

// 写
root.style.setProperty('--color-primary', '#e11d48')

比如让用户自选主题色:

colorPicker.addEventListener('input', (e) => {
  document.documentElement.style.setProperty('--color-primary', e.target.value)
})

一行就让全站主色变了,不需要重新编译任何 CSS。

总结

CSS 变量做主题切换的完整套路:

  1. 分层:底层调色板 + 语义层,组件只用语义层。
  2. 切换:在 <html> 上用 data-theme 切一组语义变量,所有引用处自动更新。
  3. 跟随系统:prefers-color-scheme 做默认,localStorage 记住用户的手动选择,后者优先。
  4. 防闪烁:把读取主题的逻辑做成 <head> 里的同步内联脚本,先于渲染执行。
  5. 过渡:只过渡颜色属性,别用 transition: all

掌握这套,你做出来的暗色模式不仅能用,而且没有刷新闪烁、能跟随系统、还能让用户自定义主题色——这就是 CSS 变量真正的价值。