☰
多主题与深色模式双轴切换:CSS变量与令牌体系实战
2026/10/17 13:38:48 网站建设 项目流程

如果你接手过那种既要支持多套品牌主题、又要同时兼容浅色深色模式的后台系统,应该懂这不是简单写两套 CSS 变量就能糊弄过去的事。只做黑和白叫“深色模式适配”,但一旦叠加了多个主题,问题就从“两套颜色”变成了“主题数 × 2 个模式组合”的矩阵。最典型的情况:同一个工作台,租户 A 用蓝色主题,租户 B 用绿色主题,每个主题下面还得有正常模式和深色模式,加起来就是 4 套视觉呈现,后面可能还会加节日主题、自定义主题色。

我当时在一个后台项目里踩完这个坑后,把整套逻辑重新梳理了一遍。核心思路是两件事:第一,把“主题”和“模式”当成两个独立维度,不要混在一起复制颜色;第二,用 CSS 自定义属性 + 根节点上的数据属性做双轴切换。这篇文章会把方案设计、令牌分层、具体代码、常见坑点全部过一遍,适合正在做换肤需求,或者准备给现有项目做深色模式改造的开发者参考。

1. 先把问题掰开:多主题与深色模式是两个独立维度

1.1 从“换色”到“双轴矩阵”

很多人一开始的思路是这样的:给系统做一个浅色主题,再做一套深色主题,然后每个品牌主题再单独复制一份深色样式。听起来很直觉,但实际维护起来会崩溃。假设有 3 个品牌主题、2 种模式,你就得维护 6 套完整配色;如果哪天某个主题要微调一个颜色,你得同时改浅色、深色两处;如果某个模式下的组件样式有 bug,要在 6 套代码里各找一遍。

这还没算用户自定义主题的情况。自定义主题意味着颜色是运行时动态算出来的,你根本没法提前把所有组合写死。

问题本质在于把两个维度混淆了。品牌主题控制的是“这套界面长什么样”,核心是品牌色、辅助色、渐变、特殊强调;模式控制的是“当前环境亮还是暗”,核心是背景、文本、边框、阴影、表面层次。品牌主题和模式确实会互相影响,但它们各自是独立的变化轴,正确做法是分开处理,再在渲染时叠加。

1.2 主题管“品牌”,模式管“明暗”

我习惯用两个术语区分:

  • 主题(theme):品牌维度,决定主色、次色、成功/警告/危险等语义色系,以及个别需要跟随品牌调性的背景或渐变。
  • 模式(mode):明暗维度,决定画布背景、卡片背景、文字层级、边框、阴影、滚动条、表单控件外观。

主题和模式一旦分开,切换逻辑就清晰多了:切换主题时不需要动背景和文字色,因为模式已经管理了这些;切换模式时不需要动品牌色,因为主题已经管理了品牌相关令牌。

比如一个卡片组件,它在浅色模式下面用白色背景,深色模式下用深灰色背景;这个颜色不跟随品牌。而卡片上的“确定”按钮,背景色在 brand-a 下是蓝色,在 brand-b 下是绿色,深色模式下品牌色可能稍微调亮一点以维持对比度,但颜色的明暗偏移仍然由主题维度决定,模式只是提供一个修正机会。

用矩阵来想更直观:

组合品牌色来源背景/文本来源
brand-a + lightbrand-a 主题令牌浅色模式令牌
brand-a + darkbrand-a 主题令牌(可加暗色修正)深色模式令牌
brand-b + lightbrand-b 主题令牌浅色模式令牌
brand-b + darkbrand-b 主题令牌(可加暗色修正)深色模式令牌

这样维护量不是 4 套独立配色,而是 2 套主题令牌 + 2 套模式令牌 + 若干组合修正。

1.3 正常模式到底指什么

标题里说的“正常模式”,其实就是浅色模式。很多后台项目习惯把 light 叫“正常模式”,因为用户默认看到的界面就是白底深字,没必要起一个花哨名字。

但在做方案设计时,我建议代码里统一叫 light 和 dark,不要叫 normal 和 dark。原因是:后面如果要做高对比度模式、护眼模式、防蓝光模式,normal 这个命名会把自己绕晕。light 和 dark 明确表达了明暗语义,新增第三、第四个模式时也只需要平行扩展。

命名统一了,主题和模式才能放同一个属性体系下管理。比如根节点上有这两个属性:

<html>:root { --color-primary: #2f6bff; --color-bg-canvas: #f5f6fa; --color-text-base: #1f2329; } html[data-theme='brand-b'] { --color-primary: #00a870; } html[data-mode='dark'] { --color-bg-canvas: #101014; --color-text-base: #e6e6eb; }

组件消费的不是具体色值,而是变量:

.app-header { background: var(--color-bg-canvas); color: var(--color-text-base); } .primary-button { background: var(--color-primary); color: #ffffff; }

这样做的好处是组件完全不关心当前是哪个主题、哪个模式,只管引用语义变量。如果将来新增一个主题,只要添加一段html[data-theme='xxx']的覆盖块,所有组件自动跟随。

2.2 令牌体系必须分层,否则就是一场灾难

CSS 变量本身不解决设计混乱的问题。如果我在组件里直接写var(--blue-500)、var(--white),跟写死颜色没本质区别,只是把问题延后了。真正要落地的是三层令牌体系。

第一层是基础色板(primitive tokens),比如:

:root { --blue-500: #2f6bff; --green-500: #00a870; --gray-0: #ffffff; --gray-50: #f5f6fa; --gray-900: #1f2329; }

这层变量尽量少在业务组件里直接使用,它只是原料。

第二层是语义令牌(semantic tokens),表示“这个颜色的用途”而不是“这个颜色具体长什么样”:

:root { --color-primary: var(--blue-500); --color-bg-canvas: var(--gray-50); --color-bg-surface: var(--gray-0); --color-text-base: var(--gray-900); --color-text-secondary: #5a5e66; --color-border-muted: #e5e6eb; }

第三层是组件令牌(component tokens),按组件维度消费语义令牌:

:root { --button-primary-bg: var(--color-primary); --button-primary-text: #ffffff; --card-bg: var(--color-bg-surface); --card-border: var(--color-border-muted); }

组件里建议优先写组件令牌或语义令牌,不要跨层去写var(--blue-500)。因为语义令牌会随主题或模式变化,基础色板不会。跨层引用等于绕过了主题机制。

实际项目里语义令牌和组件令牌不必分得那么绝对,小项目合并成一层也够用,但至少要有“基础色板”和“语义层”的区分。我见过不少项目只定义了十几个颜色变量,后面加主题时发现有些组件还写死了色值,不得不回头一个个揪出来,那个过程非常耗时间。

2.3 派生颜色是减少令牌数量的关键

最容易被忽略的是状态色。一个按钮通常有默认、hover、active、disabled、哪怕带透明度背景的浅色态,如果每个状态手写一个色值,令牌数量会爆炸。

我用过比较实用的方案是 HSL 三要素变量加颜色计算函数。比如定义一个品牌色的 hue、saturation、lightness:

:root { --brand-h: 221; --brand-s: 95%; --brand-l: 58%; --color-primary: hsl(var(--brand-h) var(--brand-s) var(--brand-l)); --color-primary-hover: hsl(var(--brand-h) var(--brand-s) calc(var(--brand-l) - 6%)); --color-primary-active: hsl(var(--brand-h) var(--brand-s) calc(var(--brand-l) - 12%)); --color-primary-soft: hsl(var(--brand-h) calc(var(--brand-s) - 10%) 96%); } html[data-theme='brand-b'] { --brand-h: 162; --brand-s: 100%; --brand-l: 33%; }

品牌主题切换时,只需要覆盖三个数值,hover、active、浅色背景全部自动跟着变。这个思路在深色模式里更香:深色模式下不希望 hover 变暗,反而要变亮一点,可以通过模式覆盖来调整:

html[data-mode='dark'] { --color-primary-hover: hsl(var(--brand-h) var(--brand-s) calc(var(--brand-l) + 6%)); --color-primary-soft: hsl(var(--brand-h) calc(var(--brand-s) - 10%) 20%); }

这样 “主题决定基色、模式决定明暗方向” 就落在具体代码里了。

如果项目浏览器兼容性允许,color-mix()也能做类似事情:

html[data-mode='dark'] { --color-primary-soft: color-mix(in srgb, var(--color-primary) 25%, transparent); }

但要注意旧版浏览器的支持情况,我一般把它当增强手段,HSL 方案作为兜底。

2.4 根节点上的属性优先级怎么算

很多人搞不清>html[data-theme='brand-b'][data-mode='dark'] { --color-primary: hsl(160, 85%, 55%); }

这不会影响 brand-b 浅色模式,也不会影响 brand-a 深色模式。

还要注意一个细节:CSS 选择器优先级相同的情况下,后定义的规则会覆盖先定义的规则。所以主题覆盖块和模式覆盖块在文件里的顺序要保持稳定,我习惯先写主题块,再写模式块,最后写组合修正块,避免团队协作时因为插入位置不同导致覆盖失效。

3. 实操记录:一个后台项目接入四套组合

3.1 先写基础色板和默认浅色令牌

拿我之前做的某云管平台为例。需求是支持两个内置品牌主题,外加一个用户自定义主色能力,所有主题都要支持浅色和深色。

我先把基础色板放到 CSS 文件最前面:

:root { --gray-0: #ffffff; --gray-50: #f7f8fa; --gray-100: #f0f1f4; --gray-200: #e5e6eb; --gray-400: #a0a3ab; --gray-600: #5a5e66; --gray-900: #1f2329; --blue-500: #2f6bff; --green-500: #00a870; --red-500: #f54e4e; --orange-500: #ff8f1f; --purple-500: #7b61ff; }

然后是默认语义令牌,也就是品牌 a 浅色的值:

:root { --brand-h: 221; --brand-s: 95%; --brand-l: 58%; --color-primary: hsl(var(--brand-h) var(--brand-s) var(--brand-l)); --color-primary-hover: hsl(var(--brand-h) var(--brand-s) calc(var(--brand-l) - 6%)); --color-primary-active: hsl(var(--brand-h) var(--brand-s) calc(var(--brand-l) - 12%)); --color-primary-soft: hsl(var(--brand-h) calc(var(--brand-s) - 10%) 96%); --color-bg-canvas: var(--gray-50); --color-bg-surface: var(--gray-0); --color-bg-elevated: var(--gray-0); --color-text-base: var(--gray-900); --color-text-secondary: var(--gray-600); --color-text-inverse: var(--gray-0); --color-border-default: var(--gray-200); --color-border-muted: var(--gray-100); --color-focus-ring: hsl(var(--brand-h) var(--brand-s) var(--brand-l) / 0.35); }

这里有个经验:背景不要用纯白#ffffff,文字不要用纯黑#000000。浅色模式下我用灰色 50 作为页面画布,白色作为卡片表面,文字用接近黑但更柔和的深灰。深色模式同理,避免大片纯黑,看起来太压。

3.2 添加品牌主题和深色模式覆盖

第二个品牌主题只改一个色相匹配器:

html[data-theme='brand-b'] { --brand-h: 162; --brand-s: 100%; --brand-l: 33%; }

由于所有主色、状态色都从这三个数值派生,这个主题瞬间就成立了。接下来是深色模式覆盖:

html[data-mode='dark'] { --color-bg-canvas: #101014; --color-bg-surface: #18181d; --color-bg-elevated: #202027; --color-text-base: #e6e6eb; --color-text-secondary: #a0a3ab; --color-text-inverse: #101014; --color-border-default: #2e2e36; --color-border-muted: #24242b; --color-primary-hover: hsl(var(--brand-h) var(--brand-s) calc(var(--brand-l) + 6%)); --color-primary-active: hsl(var(--brand-h) var(--brand-s) calc(var(--brand-l) + 12%)); --color-primary-soft: hsl(var(--brand-h) calc(var(--brand-s) - 10%) 18%); --color-focus-ring: hsl(var(--brand-h) var(--brand-s) var(--brand-l) / 0.5); }

再补一个组合修正。brand-a 的蓝色在浅色下挺合适,但深色下偏亮,稍微降一点亮度;brand-b 的绿色在深色下偏暗,稍微提亮:

html[data-theme='brand-a'][data-mode='dark'] { --brand-l: 52%; } html[data-theme='brand-b'][data-mode='dark'] { --brand-l: 40%; }

3.3 组件消费方式与原生控件适配

在组件样式里,我尽量只写语义令牌,比如通用按钮:

.btn { display: inline-flex; align-items: center; padding: 8px 16px; border-radius: 6px; border: 1px solid transparent; cursor: pointer; transition: background-color 0.2s ease; } .btn-primary { background: var(--color-primary); color: var(--color-text-inverse); } .btn-primary:hover { background: var(--color-primary-hover); } .card { background: var(--color-bg-surface); border: 1px solid var(--color-border-default); border-radius: 10px; }

深色模式下原生表单控件如果不管会非常刺眼,比如下拉框、日期选择器、滚动条,它们默认是浅色的。解决办法是让浏览器感知当前模式:

:root { color-scheme: light dark; } html[data-mode='light'] { color-scheme: light; } html[data-mode='dark'] { color-scheme: dark; }

加上color-scheme之后,Chrome 和 Safari 的原生滚动条、表单控件、日期控件会自动应用深色外观,不用逐个写样式。这是个很省事但经常被忽略的设置。

3.4 切换逻辑与首屏防闪白

切换最简单的做法是操作根节点的 data 属性。我封装了一个小工具:

type ThemeName = 'brand-a' | 'brand-b'; type ModeName = 'light' | 'dark' | 'auto'; const THEME_STORAGE_KEY = 'app-theme'; const MODE_STORAGE_KEY = 'app-mode'; function resolveMode(mode: ModeName): 'light' | 'dark' { if (mode === 'auto') { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } return mode; } function setThemeAndMode(theme: ThemeName, mode: ModeName) { const root = document.documentElement; root.setAttribute('data-theme', theme); root.setAttribute('data-mode', resolveMode(mode)); localStorage.setItem(THEME_STORAGE_KEY, theme); localStorage.setItem(MODE_STORAGE_KEY, mode); }

监听系统模式切换时要注意优先级。用户如果手动选了 light 或 dark,系统怎么变都不能干扰;只有在 auto 状态下才跟随系统:

const darkModeMedia = window.matchMedia('(prefers-color-scheme: dark)'); darkModeMedia.addEventListener('change', () => { const currentMode = localStorage.getItem(MODE_STORAGE_KEY) as ModeName | null; if (currentMode === 'auto') { document.documentElement.setAttribute('data-mode', darkModeMedia.matches ? 'dark' : 'light'); } });

首屏闪白是个很烦的体验问题。如果切换模式后刷新页面,CSS 加载完之前根节点没有><head> <script> (function () { var theme = localStorage.getItem('app-theme') || 'brand-a'; var mode = localStorage.getItem('app-mode') || 'auto'; var resolved = mode; if (resolved === 'auto') { resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-mode', resolved); })(); </script> <link rel="stylesheet" href="app.css" /> </head>

这段脚本不需要关心依赖,也不需要等 DOM 就绪,同步执行即可。我在多个项目里验证过,能有效消除刷新闪白。

3.5 新增一个主题到底要改多少东西

很多团队评估换肤成本时总担心“是不是又要大改”,其实令牌化之后,新增一个主题的成本极低。

以当前项目为例,加一个 brand-c 紫色主题只需要加一段:

html[data-theme='brand-c'] { --brand-h: 265; --brand-s: 90%; --brand-l: 62%; } html[data-theme='brand-c'][data-mode='dark'] { --brand-l: 70%; }

没了。组件、页面、图表颜色引用全部复用,因为消费端只认--color-primary、--color-bg-surface这些语义令牌。这个收益会随着项目规模增大越来越明显。

如果做用户自定义主题,逻辑也一样,只是把这三个数值从输入控件里取出来,动态写到根节点的 inline style 上:

const root = document.documentElement; root.style.setProperty('--brand-h', String(hue)); root.style.setProperty('--brand-s', saturation + '%'); root.style.setProperty('--brand-l', lightness + '%');

用户选完颜色,整个界面就跟着换了。这一步如果当初把品牌色调死在各组件里,根本做不到。

4. 踩坑实录:双轴切换的常见问题与排查

4.1 切换后总有几个控件颜色不跟着走

这是换肤需求里最常见的抱怨。排查顺序我建议先得分清是框架问题还是样式问题。右键打开检查,看目标元素的 computed style,确认它到底有没有吃到 CSS 变量。

常见原因有三个:第一,组件里用内联样式写死了色值,这种在第三方表格、富文本编辑器组件里特别多;第二,组件库自带一套主题变量,需要在全局配置里把它的 token 映射到我们的语义变量上;第三,元素渲染在 shadow DOM 里,比如某些自定义元素组件,外层 CSS 变量默认进不去,需要显式继承变量或改用构造样式表。

实际处理时,我一般优先做全局映射层,把外部组件的主题变量统一接到我们的令牌上:

.some-widget { --widget-primary: var(--color-primary); --widget-bg: var(--color-bg-surface); }

尽量避免直接去改组件库的深层内部样式,否则升级依赖时会很难受。

4.2 闪白和过渡动画反人类

闪白问题在前面已经加了 inline 脚本解决。但还有一个细节:切换瞬间所有颜色沿着 transition 缓缓变化,看起来像整个页面“融化”了一下,很诡异。

如果希望切成即时的,可以在切换前临时禁用 transition:

function changeThemeWithoutTransition(fn: () => void) { const root = document.documentElement; root.classList.add('disable-transitions'); fn(); requestAnimationFrame(() => { root.classList.remove('disable-transitions'); }); }

CSS 配套:

.disable-transitions *, .disable-transitions *::before, .disable-transitions *::after { transition: none !important; }

深色模式跟浅色模式之间的切换通常应该快、准、狠,用户不需要看过程。鼠标 hover 的过渡动画保留就行。

4.3 深色模式把某个主题的组件带崩了

这是双轴设计最容易出的问题。症状一般是:brand-a 深色下一切正常,切到 brand-b 深色后,按钮文字看不清、卡片上的标签和背景糊在一起。原因通常是主题和模式都定义了同一个变量的不同值,但覆盖顺序想当然了。

比如我在某个早期版本里,深色模式覆盖了--color-primary-soft,而主题块也覆盖了这个变量,结果某个主题下 hover 态被主题层的黄色盖住,变成一坨亮黄,几乎看不清文字。

后来我定了一条团队铁律:主题层只覆盖品牌派生相关令牌,模式层只覆盖中性色和明暗相关令牌,组合层处理交集。每条规则在提交时都要说明它属于哪一维度,代码审查时如果看到主题块里混进了 bg-canvas 之类的变量,直接打回。这样维度边界守住之后,问题发生率明显下降。

4.4 对比度与无障碍比想象中重要

深色模式最容易被忽略的是文字对比度。浅色下肉眼看着挺舒服的灰色到了深色下可能低于 WCAG AA 标准。我后来在 CI 流程里加了一个简单检查,拉取每个模式下的语义色对,计算对比度:

function contrastRatio(colorA, colorB) { const luminance = (color) => { const channels = color.match(/\d+/g).slice(0, 3).map((c) => c / 255); const linear = channels.map((c) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) ); return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; }; const lumA = luminance(colorA); const lumB = luminance(colorB); const [lighter, darker] = lumA > lumB ? [lumA, lumB] : [lumB, lumA]; return (lighter + 0.05) / (darker + 0.05); }

一般正文文字和背景的对比度要确保在 4.5 以上,大号文字可以放宽到 3。深色模式下,常见的坑是次要文字用了灰色 400 或者更低,结果对比度只有 2 左右,用户看起来很吃力。

如果产品有高对比度需求,还可以考虑支持forced-colors等用户偏好,不过大多数后台项目做到 AA 级已经不错了。我会把这件事写进验收清单,而不是等到用户反馈再来调。

4.5 图表和 Canvas 是第二战场

CSS 变量能解决 DOM 元素的换肤,但 canvas 不行。图表库绘制时会把颜色读进 JavaScript,主题切了之后图表还是旧颜色,非常割裂。

我的处理方法是:图表组件监听根节点属性变化,重新读取当前主题下的令牌值,再调用图表实例更新。比如:

function getChartTheme() { const styles = getComputedStyle(document.documentElement); return { primary: styles.getPropertyValue('--color-primary').trim(), bgSurface: styles.getPropertyValue('--color-bg-surface').trim(), textSecondary: styles.getPropertyValue('--color-text-secondary').trim(), }; } const observer = new MutationObserver(() => { chart.updateOption({ color: [getChartTheme().primary], backgroundColor: getChartTheme().bgSurface, }); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-mode'], });

图片也是容易被忽略的地方。深色模式下一些原本白底的图片会显得很刺眼,常见的折中方案是给图片容器加一层混合模式或轻微降低亮度。但图片处理没有通用银弹,最好是在设计侧约定:深色模式可接受的图片要尽量用透明背景或深色背景。

5. 做双轴主题切换最值得养成的几个习惯

我自己前后改过两版方案,第一版把主题和模式混在一起写,一个品牌定义一个总的背景色,后来新增第二个品牌时发现每个组件的阴影、边框、悬浮态都得重新覆写一遍,气得全部推翻。第二版老老实实拆成主题轴和模式轴,再往后加品牌、加自定义色,都是几段 CSS 的事。

调试时我习惯直接在 DevTools 里改document.documentElement的>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询