多主题与深色模式双轴切换:CSS变量与令牌体系实战
发布时间:2026/10/17 13:39:00 锦皓数字建站

如果你接手过那种既要支持多套品牌主题、又要同时兼容浅色深色模式的后台系统应该懂这不是简单写两套 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-themebrand-b] { --color-primary: #00a870; } html[data-modedark] { --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-themexxx]的覆盖块所有组件自动跟随。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-themebrand-b] { --brand-h: 162; --brand-s: 100%; --brand-l: 33%; }品牌主题切换时只需要覆盖三个数值hover、active、浅色背景全部自动跟着变。这个思路在深色模式里更香深色模式下不希望 hover 变暗反而要变亮一点可以通过模式覆盖来调整html[data-modedark] { --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-modedark] { --color-primary-soft: color-mix(in srgb, var(--color-primary) 25%, transparent); }但要注意旧版浏览器的支持情况我一般把它当增强手段HSL 方案作为兜底。2.4 根节点上的属性优先级怎么算很多人搞不清>html[data-themebrand-b][data-modedark] { --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-themebrand-b] { --brand-h: 162; --brand-s: 100%; --brand-l: 33%; }由于所有主色、状态色都从这三个数值派生这个主题瞬间就成立了。接下来是深色模式覆盖html[data-modedark] { --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-themebrand-a][data-modedark] { --brand-l: 52%; } html[data-themebrand-b][data-modedark] { --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-modelight] { color-scheme: light; } html[data-modedark] { 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 relstylesheet hrefapp.css / /head这段脚本不需要关心依赖也不需要等 DOM 就绪同步执行即可。我在多个项目里验证过能有效消除刷新闪白。3.5 新增一个主题到底要改多少东西很多团队评估换肤成本时总担心“是不是又要大改”其实令牌化之后新增一个主题的成本极低。以当前项目为例加一个 brand-c 紫色主题只需要加一段html[data-themebrand-c] { --brand-h: 265; --brand-s: 90%; --brand-l: 62%; } html[data-themebrand-c][data-modedark] { --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 缓缓变化看起来像整个页面“融化”了一下很诡异。如果希望切成即时的可以在切换前临时禁用 transitionfunction 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的>
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。