Back

使用 CSS 变量构建主题切换器

使用 CSS 变量构建主题切换器

样式表中散落的硬编码颜色值会让主题切换变得异常痛苦。改一个颜色,你就得在几十处声明中翻找。CSS 变量优雅地解决了这个问题——只需定义一次设计令牌(design tokens),然后通过修改根元素上的一个属性即可切换主题。

本文将带你一步步构建一个易于维护的主题切换器:它能自动遵循系统偏好、响应用户的手动选择,并在会话之间持久化这一选择。

关键要点

  • CSS 自定义属性是动态的,可在运行时更新,是主题化场景下理想的设计令牌。
  • 在根元素上使用 data-theme 属性,提供了一种简洁且可扩展的方式来切换任意数量的主题。
  • prefers-color-scheme 媒体查询负责处理系统默认设置,而 JavaScript 和 localStorage 则用于保存用户的手动选择。
  • <head> 中放置一段小型内联脚本,可避免页面加载时出现错误主题闪烁。
  • 设置 color-scheme 属性可确保浏览器原生 UI 元素与当前主题相匹配。

为什么 CSS 变量是主题化的理想基础

CSS 自定义属性是动态的。与编译为静态值的 Sass 变量不同,CSS 变量存在于浏览器中,可以在运行时被更新——通过媒体查询、通过 JavaScript,或通过父元素状态的变化。

这使它们成为理想的设计令牌。将表面色、文字色和强调色定义为 :root 上的变量,然后在整个样式表中引用这些变量。切换主题就变成了单次 DOM 修改,而不是替换整个样式表。

设置你的主题令牌

首先将浅色主题定义为默认值,然后使用 data-theme 属性选择器声明深色变体:

:root {
  color-scheme: light;
  --color-bg: #ffffff;
  --color-text: #1a1a1a;
  --color-primary: #302ae6;
  --color-surface: #f4f4f4;
}

[data-theme="dark"] {
  color-scheme: dark;
  --color-bg: #161625;
  --color-text: #e1e1ff;
  --color-primary: #9a97f3;
  --color-surface: #1e1e30;
}

color-scheme 属性值得加上。它会告诉浏览器当前处于哪种模式,这样原生 UI 元素——滚动条、表单输入框、聚焦轮廓等——就会自动与你的主题相匹配。

接下来在整个样式表中使用这些令牌:

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

使用 prefers-color-scheme 遵循系统偏好

系统偏好和手动主题切换解决的是不同的问题。prefers-color-scheme 处理的是自动场景——那些将操作系统设为深色模式、并期望网站随之变化的用户:

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --color-bg: #161625;
    --color-text: #e1e1ff;
    --color-primary: #9a97f3;
    --color-surface: #1e1e30;
  }
}

:not([data-theme="light"]) 守卫条件确保用户的明确选择能够覆盖系统默认值。否则,该媒体查询会覆盖手动选择。

关于 light-dark() 的说明

light-dark() CSS 函数允许你在一行内声明两种主题的值:

:root {
  color-scheme: light dark;
  --color-bg: light-dark(#ffffff, #161625);
}

对于较为简单的令牌集合,以及只需要两种主题的场景,它非常优雅。但如果系统中有超过两种主题,或令牌涉及的不仅仅是颜色,基于属性选择器的方案会给你更多的控制权。

请注意,light-dark() 要求 color-scheme 同时设置两个值,函数才能正确解析。现代浏览器对它的支持已经相当不错,但相对较新(Chrome 123+、Safari 17.5+、Firefox 120+),所以在依赖它之前请先确认你的受众兼容性。

添加 JavaScript 实现持久化的手动控制

系统偏好检测可以纯 CSS 实现,但记住用户的手动选择需要 JavaScript。下面是一个最小化的、可用于生产的实现:

const STORAGE_KEY = 'theme-preference';
const root = document.documentElement;

function getSystemTheme() {
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

function getSavedTheme() {
  try {
    return localStorage.getItem(STORAGE_KEY);
  } catch {
    return null;
  }
}

function applyTheme(theme) {
  root.setAttribute('data-theme', theme);

  try {
    localStorage.setItem(STORAGE_KEY, theme);
  } catch {
    // Storage unavailable — theme still applies for this session
  }
}

// On load: respect saved preference, fall back to system
applyTheme(getSavedTheme() || getSystemTheme());

// Wire up your toggle button
document.getElementById('theme-toggle').addEventListener('click', () => {
  const current = root.getAttribute('data-theme');
  applyTheme(current === 'dark' ? 'light' : 'dark');
});

// Sync with OS changes when no manual preference is set
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
  if (!getSavedTheme()) {
    root.setAttribute('data-theme', e.matches ? 'dark' : 'light');
  }
});

围绕 localStoragetry/catch 块可以优雅地处理隐私浏览模式和存储配额错误——即便无法保存,主题在当前会话中仍然可以生效。

防止错误主题闪烁

如果你的 JavaScript 在页面渲染之后才加载,用户会短暂看到默认主题,然后才切换到正确的主题。可以通过在 <head> 中放置一段小型阻塞式内联脚本来解决:

<script>
  try {
    const saved = localStorage.getItem('theme-preference');

    if (saved) {
      document.documentElement.setAttribute('data-theme', saved);
    } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
      document.documentElement.setAttribute('data-theme', 'dark');
    }
  } catch {
    if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
      document.documentElement.setAttribute('data-theme', 'dark');
    }
  }
</script>

这段脚本会在任何内容渲染之前同步执行,消除闪烁。请尽可能将它放在 <head> 的最前面,放在任何样式表之前,这样在 CSS 首次应用时该属性就已存在。

这一模式具备良好的可扩展性

CSS 变量让你的设计令牌和组件样式之间形成了清晰的分离。组件引用变量;主题定义变量。添加第三种主题——高对比度、品牌变体、季节性配色——只需在 CSS 中新增一个属性选择器块,并在切换逻辑中增加一个新选项。其他都无需改动。

结语

这里真正的价值不仅仅是深色模式——而是一种可随设计系统不断扩展、并保持可维护性的主题化架构。把 CSS 变量当作设计令牌,同时尊重系统偏好与用户偏好,并妥善处理各种边界情况(存储失败、渲染闪烁、操作系统级变更),你就能构建出一个既贴近平台原生体验、又易于扩展的切换器。这一模式体量很小,但每新增一个主题或组件,它的收益都会不断累积。

常见问题

两种方式都可行,但 data-theme 属性通常更简洁,因为它表达的是单一值,而非一组修饰符。class 更适合多个标志同时存在的组合状态。对于主题来说,通常同一时刻只有一个激活值,这与属性选择器的语义更为契合。

这种闪烁发生在浏览器已使用默认样式绘制完页面之后,JavaScript 才执行的情况下。解决办法是在 head 中放置一段小型同步内联脚本,从 localStorage 读取已保存的偏好,并在任何渲染发生之前就设置好 data-theme 属性。对于首次访问的用户,你还可以检查 prefers-color-scheme,从而让初次渲染立刻匹配系统主题。

可以。CSS 变量可以承载任何合法的 CSS 值,包括间距尺度、圆角半径、阴影、字体栈和动画时长。这让它们也适用于密度主题化(紧凑布局与舒适布局)、动效偏好,甚至排版变体。要广义地把变量视为设计令牌,而不仅仅是颜色引用。

它在现代浏览器中工作良好(Chrome 123+、Safari 17.5+、Firefox 120+),但在旧版本中缺乏支持。如果你的受众都在使用现代浏览器,这是一个简洁的选择。要获得更广泛的兼容性,data-theme 属性方案仍然更稳妥,而且它还能扩展到两种以上的主题,这是 light-dark() 自身做不到的。

Truly understand users experience

See every user interaction, feel every frustration and track all hesitations with OpenReplay — the open-source digital experience platform. It can be self-hosted in minutes, giving you complete control over your customer data. . Check our GitHub repo and join the thousands of developers in our community..

OpenReplay