Multi-Theme Blog Design in Hexo: Theme Switching With CSS Variables and the :Where Selector
This post shows how to manage multiple blog themes with CSS variables and the :where selector, covering theme switching for Shiki code highlighting and a fix for the flash of unstyled content (FOUC)
Preview
Setup Multi-Themes with CSS Variables
The core of multi-theme support is decoupling color values from CSS rules:
- Set a
data-themeattribute on the HTML root element to identify the current theme (such asnord,tokyo_night); when switching themes, update this attribute dynamically - For each
data-themevalue, define CSS variables with the same names::where([data-theme=nord]) { --red: #d20f39; }:where([data-theme=tokyo_night]) { --red: #f7768e; }
- Component color styles all reference these variables via
var(--color-var)
Define Color Variables
To define theme-specific variables, use the :where selector[1]; it is also worth setting the color-scheme[2] property, which tells the browser whether the current theme is light or dark and affects the system default styles of scrollbars and form controls:where([data-theme="nord"]) { color-scheme: light; --red: #d20f39;}:where([data-theme="tokyo_night"]) { color-scheme: dark; --red: #f7768e;}
With these CSS rules in place, page elements pick up the color values of the current theme dynamically.
For example, in the page below, the h1 element uses the --red value from the nord theme, #d20f39. When the user switches to the tokyo_night theme, the h1 element picks up the new --red value, #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
There are two ways to implement a “follow system theme” option:
The CSS approach works like this: first define the color variables for data-theme="system", defaulting to the light theme. After that rule, use a @media (prefers-color-scheme: dark) media query to detect the system’s dark preference; if the system prefers dark, redefine the dark theme values for :where([data-theme="system"]), overriding the earlier light values. When the user chooses “follow system”, simply set data-theme to "system" and the browser applies the right color variables based on the system preference. The drawback of this approach is the redundant variable definitions, which raise the maintenance cost
/* ... light ... */:where([data-theme="system"]) { color-scheme: light dark; --rosewater: #dc8a78;}/* ... night ... */@media (prefers-color-scheme: dark) { :where([data-theme="system"]) { --rosewater: #f5e0dc; }}With JavaScript, you can set the data-theme attribute to a concrete theme name based on the system preference. The resolveTheme function checks the current theme value: if it is "system", it detects dark mode via window.matchMedia("(prefers-color-scheme: dark)") and returns the corresponding theme name (such as "mocha" or "nord"); for any other fixed theme, it returns that theme name directly. The applyTheme function applies the resolved theme to the HTML element.
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
To round out the theme switching experience, two problems remain:
Persistence: the theme the user picks should survive page reloads and browser restarts. Save the choice locally with localStorage and restore it on the next visit.const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");// Read the user's chosen theme from localStoragefunction getThemePreference() { const stored = localStorage.getItem(STORAGE_KEY); return stored && stored in THEME_MAP ? stored : DEFAULT_THEME;}// Called when the user picks a theme, with 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: when the user has picked “follow system” and the system preference changes (say, from light mode to dark mode), the page should respond and update the theme immediately.colorSchemeMediaQuery.addEventListener("change", () => { if (getThemePreference() === "system") { applyTheme("system"); }});
Transparent Colors
Example
Some components need semi-transparent colors. This paragraph, for example, uses the Callout component. A simple but inelegant way to handle this::where([data-theme="nord"]) { --red: #d20f39; --red-10: rgba(210, 15, 57, 0.1); /* 10% opacity */ --red-50: rgba(210, 15, 57, 0.5); /* 50% opacity */ --green: #40a02b; --green-10: rgba(64, 160, 43, 0.1); --green-50: rgba(64, 160, 43, 0.5); /* other colors */}
If transparent colors are used widely, the variable count multiplies; this approach takes real effort and is costly to maintain. A better option is CSS relative color syntax[3] for defining transparent colors: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)
For the red in the example above, you can reuse the --red variable directly to define a semi-transparent color:/* red background at 50% opacity */background-color: hsl(from var(--red) h s l / 0.5);
Browser compatibility[4] is so-so, but for a personal blog it hardly matters.
Avoid FOUC
One way to avoid a flash[5] on initial render: insert an IIFE (Immediately Invoked Function Expression) script into <head>, guaranteeing the data-theme attribute is set before the page renders<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
For code highlighting, this blog uses Shiki for static rendering. Shiki accepts multiple themes. For example:const themes = { light: "catppuccin-latte", dark: "catppuccin-mocha", tokyo: "tokyo-night",};const 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>;
It is easy to see that the With more themes, the generated span tags become bloated. The ideal solution is to generate a dedicated class for each color. Shiki provides the transformerstyletoclass transformer, which generates HTML like this Use the data-theme attribute, combined with CSS variables, gives us multi-theme color support: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 to obtain the corresponding CSS file; see my post on this for details.__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. ↩︎