Implementing a Multi-Theme System With CSS Variables and Shiki
本文介绍如何利用 CSS 变量与 :where 选择器实现博客多主题管理,包括 Shiki 代码高亮主题切换与页面闪烁(FOUC)的解决方案
Preview
Setup Multi-Themes with CSS Variables
实现多主题的核心在于解耦颜色值与 CSS 规则:
- 在 HTML 根元素上设置
data-theme属性标识当前主题(如nord、tokyo_night);切换主题时,动态修改该属性值 - 针对不同
data-theme值,定义同名的 CSS 变量::where([data-theme=nord]) { --red: #d20f39; }:where([data-theme=tokyo_night]) { --red: #f7768e; }
- 组件的颜色样式,均通过
var(--color-var)引用对应变量
Define Color Variables
针对不同主题定义专属变量,可使用 :where 选择器[1];同时建议设置 color-scheme[2] 属性,告知浏览器当前主题是亮色还是暗色,这会影响滚动条和表单控件的系统默认样式:where([data-theme="nord"]) { color-scheme: light; --red: #d20f39;}:where([data-theme="tokyo_night"]) { color-scheme: dark; --red: #f7768e;}
应用如上CSS规则后,页面元素即可根据当前主题动态应用对应颜色值。
例如对于如下页面,h1 元素会应用 nord 主题下的 --red 变量值,即 #d20f39。当用户切换至 tokyo_night 主题后,h1 元素则会应用新的 --red 变量值,即 #f7768e<!-- apply nord theme --><html data-theme="nord"> <head> <link rel="stylesheet" href="color.css" /> </head> <body> <h1 style="color: var(--red)">Hello, World!</h1> </body></html>
Follow System Theme
对于"跟随系统主题"选项的实现,有两种实现路径:
CSS 方案的思路是:先定义 data-theme="system" 情况下的颜色变量,这里默认采用浅色主题。在该规则后面,通过 @media (prefers-color-scheme: dark) 媒体查询检测系统的暗色偏好,如果系统偏好为暗色,则再次针对 :where([data-theme="system"]) 重新定义暗色主题的变量值,覆盖之前的浅色值。当用户选择"跟随系统"时,只需将 data-theme 设置为 "system",浏览器会根据系统偏好自动应用对应的颜色变量。该方案的局限是有冗余变量定义,维护成本较高/* ... light ... */:where([data-theme="system"]) { color-scheme: light dark; --rosewater: #dc8a78;}/* ... night ... */@media (prefers-color-scheme: dark) { :where([data-theme="system"]) { --rosewater: #f5e0dc; }}
使用 JavaScript 可根据系统偏好动态设置 data-theme 属性为具体主题名。resolveTheme 函数会检查当前主题值,如果是 "system",则通过 window.matchMedia("(prefers-color-scheme: dark)") 检测系统是否处于暗色模式,并返回对应的主题名称(如 "mocha" 或 "nord");如果是其他固定主题,则直接返回该主题名称。applyTheme 函数负责将解析后的主题应用到 HTML 元素上。const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");function resolveTheme(theme) { return theme === "system" ? colorSchemeMediaQuery.matches ? "mocha" : "nord" : theme;}function applyTheme(theme) { const html = document.documentElement; const resolvedTheme = resolveTheme(theme); html.setAttribute("data-theme", resolvedTheme); html.classList.remove("night", "light"); html.classList.add(THEME_MAP[resolvedTheme]);}
Theme Persistence and Responsiveness
为了让主题切换体验更加完善,需要解决两个问题:
主题持久化(Persistence):用户选择的主题应该在刷新页面或关闭浏览器后依然生效。通过 localStorage 将用户的选择保存在本地,下次访问时自动恢复。const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");// 从 localStorage 获取用户选择的主题function getThemePreference() { const stored = localStorage.getItem(STORAGE_KEY); return stored && stored in THEME_MAP ? stored : DEFAULT_THEME;}// 当用户选择主题时,调用该方法,且传入persist=Truefunction applyTheme(theme, persist) { const html = document.documentElement; const resolvedTheme = resolveTheme(theme); html.setAttribute("data-theme", resolvedTheme); html.classList.remove("night", "light"); html.classList.add(THEME_MAP[resolvedTheme]); if (persist) { localStorage.setItem(STORAGE_KEY, theme); }}
主题响应性(Responsiveness):当用户使用"跟随系统"选项时,如果系统偏好发生变化(如从浅色模式切换到深色模式),网页应该立即响应并更新主题。colorSchemeMediaQuery.addEventListener("change", () => { if (getThemePreference() === "system") { applyTheme("system"); }});
Transparent Colors
Example
一些组件可能需要使用半透明颜色。例如这段文字,使用了 Callout 组件。针对这种需求,一种简单但不优雅的方案是::where([data-theme="nord"]) { --red: #d20f39; --red-10: rgba(210, 15, 57, 0.1); /* 10% 透明度 */ --red-50: rgba(210, 15, 57, 0.5); /* 50% 透明度 */ --green: #40a02b; --green-10: rgba(64, 160, 43, 0.1); --green-50: rgba(64, 160, 43, 0.5); /* 其他颜色 */}
如需广泛使用透明颜色,变量数量会成倍增加;该方法颇费人力,维护成本也不小。更推荐使用 CSS 的 relative color syntax[3] 来实现透明颜色的定义:color-function(from origin-color channel1 channel2 channel3)color-function(from origin-color channel1 channel2 channel3 / alpha)/* color space included for color() functions */color(from origin-color colorspace channel1 channel2 channel3)color(from origin-color colorspace channel1 channel2 channel3 / alpha)
对于上例中的红色,便可直接通过如下方式复用 --red 变量,定义半透明色:/* 50% 透明度的红色背景 */background-color: hsl(from var(--red) h s l / 0.5);
浏览器兼容性[4]一般,不过对于个人博客来说,影响不大。
Avoid FOUC
一个避免页面初始渲染时出现 Flash[5]的方案是:在 <head> 插入 IIFE(Immediately Invoked Function Expression)脚本,保证在页面渲染前完成 data-theme 属性的设置<html lang="zh-CN" data-theme="mocha" class="night"> <head> <script> (function() { var THEME_MAP = { mocha: "night", macchiato: "night", nord: "light", nord_night: "night", tokyo_night: "night", latte: "light" }; var stored = localStorage.getItem("themePreference"); var theme = stored ? stored : "system"; var html = document.documentElement; var resolvedTheme = theme === "system" ? window.matchMedia("(prefers-color-scheme: dark)").matches ? "mocha" : "nord" : theme; html.setAttribute("data-theme", resolvedTheme); html.classList.add(THEME_MAP[resolvedTheme]); })(); </script> </head></html>
Special Components
Code Highlighting
在代码高亮方面,本博客使用 Shiki 进行静态代码高亮渲染。Shiki 支持传入多种主题。例如:const themes = { light: "catppuccin-latte", dark: "catppuccin-mocha", tokyo: "tokyo-night",};let code_html = highlighter.codeToHtml(code, { lang: options.lang || "", themes: themes, transformers: enableTransformers ? SUPPORTED_TRANSFORMERS : [],});/* Generated Span Be Like */<span style="color:#1E66F5;--shiki-dark:#89B4FA;--shiki-tokyo:#7AA2F7"> name</span>;
不难想到,我们可以通过 如果要应用更多主题,那么前面生成的 span 标签就会非常臃肿,理想的方案是为每个颜色生成单独的 class。Shiki 提供了 transformerstyletoclass 转换器,将生成如下 HTML 配合 data-theme 属性,结合 CSS 变量,来实现多主题色彩支持:code span { font-style: var(--shiki-light-font-style); font-weight: var(--shiki-light-font-weight);}:is([data-theme="tokyo_night"]) { code span { font-style: var(--shiki-tokyo-font-style) !important; font-weight: var(--shiki-tokyo-font-weight) !important; color: var(--shiki-tokyo) !important; }}
<pre class="shiki shiki-themes vitesse-dark vitesse-light __shiki_9knfln" tabindex="0"> <code> <span class="line"> <span class="__shiki_14cn0u">console</span> <span class="__shiki_ps5uht">.</span> <span class="__shiki_1zrdwt">log</span> <span class="__shiki_ps5uht">(</span> <span class="__shiki_236mh3">'</span> <span class="__shiki_1g4r39">hello</span> <span class="__shiki_236mh3">'</span> <span class="__shiki_ps5uht">)</span> </span> </code></pre>transformerStyleToClass({ classPrefix: '__shiki_'}).getCSS() API 获取相应的 CSS 文件;具体操作可以参考我的这篇文章.__shiki_14cn0u { --shiki-dark: #bd976a; --shiki-light: #b07d48;}/* ... */.__shiki_9knfln { --shiki-dark: #dbd7caee; --shiki-light: #393a34; --shiki-dark-bg: #121212; --shiki-light-bg: #ffffff;}
The CSS
:where()pseudo-class is used to apply the same style to all the elements inside the parentheses, at the same time.:where()always has 0 specificity. CSS :where Pseudo-class ↩︎Common choices for operating system color schemes are “light” and “dark”, or “day mode” and “night mode”. When a user selects one of these color schemes, the operating system makes adjustments to the user interface. This includes form controls, scrollbars, and the used values of CSS system colors. color-scheme - CSS | MDN ↩︎
The CSS colors module defines relative color syntax, which allows a CSS color value to be defined relative to another color. This is a powerful feature that enables easy creation of complements to existing colors — such as lighter, darker, saturated, semi-transparent, or inverted variants — enabling more effective color palette creation. Using relative colors - CSS | MDN ↩︎
CSS Relative color syntax | Can I use… Support tables for HTML5, CSS3, etc ↩︎
A flash of unstyled content (FOUC, or flash of unstyled text) is an instance where a web page appears briefly with the browser’s default styles prior to loading an external CSS stylesheet, due to the web browser engine rendering the page before all information is retrieved. ↩︎
Implementing a Multi-Theme System With CSS Variables and Shiki