
Theming Websites with CSS Custom Properties
Theming used to mean maintaining separate stylesheets. A "dark" stylesheet overrode every color in the "light" one, a "high contrast" version overrode them again, and any change to a component meant updating it in three places. Preprocessor variables helped organize the values, but they compiled down to fixed colors, so switching themes still meant loading different CSS.
CSS custom properties changed that. Because they're live values resolved by the browser, you can define your colors once, reference them everywhere, and swap the whole theme by changing a handful of variables. No rebuild, no second stylesheet, and no JavaScript beyond a single attribute toggle. This guide walks through building a complete theming system, from choosing variable names to respecting system preferences, preventing a flash of the wrong theme, and theming individual sections of a page.
Why Custom Properties Are Ideal for Theming
Custom properties have three characteristics that make them a natural fit.
- They cascade and inherit. A value set on
:rootis available everywhere. A value set on a specific element overrides it for that element's subtree. That's exactly how themes should work: global by default, overridable locally. - They're resolved at runtime. Changing a custom property with a class, attribute, or media query instantly updates every style that uses it.
- They can hold anything. Colors, lengths, shadows, fonts, and even partial values. A theme can change more than just colors.
Step 1: Name Your Tokens by Role, Not Value
The most important decision in a theming system is how you name variables. Compare these two approaches:
/* Named by value */
:root {
--white: #ffffff;
--dark-blue: #0f172a;
}
/* Named by role */
:root {
--color-bg: #ffffff;
--color-text: #0f172a;
}
In a dark theme, --white would have to become a dark color, which makes no sense. --color-bg can be any color, and it still means the same thing. Name tokens after what they're for, and themes become a matter of assigning new values to the same names.
A practical approach is to use two tiers:
- Palette tokens hold the raw colors of your brand.
- Semantic tokens describe roles and reference the palette.
:root {
/* Palette */
--blue-600: #2563eb;
--blue-400: #60a5fa;
--slate-50: #f8fafc;
--slate-200: #e2e8f0;
--slate-700: #334155;
--slate-900: #0f172a;
--slate-950: #020617;
/* Semantic */
--color-bg: var(--slate-50);
--color-surface: #ffffff;
--color-text: var(--slate-900);
--color-text-muted: var(--slate-700);
--color-border: var(--slate-200);
--color-accent: var(--blue-600);
--color-accent-text: #ffffff;
}
Components only ever use semantic tokens. Themes only ever change which palette values the semantic tokens point to.
Step 2: Use the Tokens in Components
With tokens in place, components never mention a raw color:
<article class="card">
<h3 class="card__title">Weekly report</h3>
<p class="card__body">Traffic is up 12% compared to last week.</p>
<a href="/reports" class="button">View report</a>
</article>
body {
background: var(--color-bg);
color: var(--color-text);
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 1.5rem;
}
.card__body {
color: var(--color-text-muted);
}
.button {
display: inline-block;
padding: 0.6rem 1.1rem;
border-radius: 8px;
background: var(--color-accent);
color: var(--color-accent-text);
text-decoration: none;
}
This is the payoff. The card and button will look right in every theme, because they don't know which theme is active. They just use the roles.
Step 3: Add a Dark Theme
A dark theme is a new set of values for the semantic tokens. The simplest version responds to the user's system preference:
@media (prefers-color-scheme: dark) {
:root {
--color-bg: var(--slate-950);
--color-surface: var(--slate-900);
--color-text: var(--slate-50);
--color-text-muted: var(--slate-200);
--color-border: var(--slate-700);
--color-accent: var(--blue-400);
--color-accent-text: var(--slate-950);
}
}
Notice the accent changes too. A bright blue that works on white often feels harsh on a dark background, and a lighter blue needs dark text for contrast. Themes aren't just inversions; each one needs its own contrast checks.
Tell the Browser with color-scheme
Also set color-scheme, so that built-in UI like scrollbars, form controls, and the default canvas color match:
:root {
color-scheme: light dark;
}
Without this, a dark-themed page can still show bright white scrollbars and checkboxes.
Step 4: Let Users Choose
Many users want to override their system setting for a specific site. A common pattern is a three-way choice: light, dark, or follow the system. Use a data-theme attribute on the root element to represent an explicit choice:
/* Explicit dark */
:root[data-theme="dark"] {
color-scheme: dark;
--color-bg: var(--slate-950);
--color-surface: var(--slate-900);
--color-text: var(--slate-50);
--color-text-muted: var(--slate-200);
--color-border: var(--slate-700);
--color-accent: var(--blue-400);
--color-accent-text: var(--slate-950);
}
/* Explicit light */
:root[data-theme="light"] {
color-scheme: light;
}
/* System preference, only when no explicit choice has been made */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
color-scheme: dark;
--color-bg: var(--slate-950);
--color-surface: var(--slate-900);
--color-text: var(--slate-50);
--color-text-muted: var(--slate-200);
--color-border: var(--slate-700);
--color-accent: var(--blue-400);
--color-accent-text: var(--slate-950);
}
}
The repetition is the price of supporting both a system default and an explicit override without extra tooling. A preprocessor mixin, or the light-dark() approach below, can remove it.
Then a small script toggles the attribute and remembers the choice:
<label for="theme-select">Theme</label>
<select id="theme-select">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
const select = document.getElementById("theme-select");
const root = document.documentElement;
function applyTheme(choice) {
if (choice === "light" || choice === "dark") {
root.dataset.theme = choice;
} else {
delete root.dataset.theme;
}
}
let saved = "system";
try {
saved = localStorage.getItem("theme") || "system";
} catch (e) {
// Storage can be unavailable in private modes; fall back to system.
}
select.value = saved;
applyTheme(saved);
select.addEventListener("change", () => {
applyTheme(select.value);
try {
localStorage.setItem("theme", select.value);
} catch (e) {}
});
Preventing the Flash of the Wrong Theme
If the script above runs at the end of the page, users who chose dark mode will briefly see the light theme on every page load. To prevent that, apply the saved theme with a tiny inline script in the head, before any content renders:
<head>
<script>
try {
const t = localStorage.getItem("theme");
if (t === "light" || t === "dark") {
document.documentElement.dataset.theme = t;
}
} catch (e) {}
</script>
<link rel="stylesheet" href="/styles.css" />
</head>
This script blocks rendering for a negligible amount of time, and the page paints with the correct theme from the start. Frameworks with server rendering often do the same thing, or store the choice in a cookie so the server can render the attribute directly.
Step 5: Simplify with light-dark()
The light-dark() function takes two colors and returns the first when the element's used color scheme is light and the second when it's dark. Combined with color-scheme, it lets you define both themes in one place:
:root {
color-scheme: light dark;
--color-bg: light-dark(var(--slate-50), var(--slate-950));
--color-surface: light-dark(#ffffff, var(--slate-900));
--color-text: light-dark(var(--slate-900), var(--slate-50));
--color-text-muted: light-dark(var(--slate-700), var(--slate-200));
--color-border: light-dark(var(--slate-200), var(--slate-700));
--color-accent: light-dark(var(--blue-600), var(--blue-400));
--color-accent-text: light-dark(#ffffff, var(--slate-950));
}
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
Now the explicit choice only needs to change color-scheme, and every token follows. color-scheme: light dark without an override follows the system preference automatically.
light-dark() is supported in all current major browsers, but it's relatively recent, so older browser versions won't understand it. If you need to support those, keep the media query version as your baseline and layer light-dark() on top with @supports (color: light-dark(white, black)). Also note that light-dark() only accepts colors; for non-color values like shadows or images that should change between themes, you still need the attribute or media query approach.
Step 6: Derive Colors Instead of Listing Them
Hover states, subtle backgrounds, and focus rings are often slight variations of your core colors. Rather than defining a token for every variation, derive them with color-mix() or relative color syntax:
.button:hover {
background: color-mix(in oklch, var(--color-accent), black 15%);
}
.badge {
background: color-mix(in oklch, var(--color-accent) 15%, transparent);
color: var(--color-accent);
}
.input:focus-visible {
outline: 3px solid oklch(from var(--color-accent) l c h / 0.5);
outline-offset: 2px;
}
color-mix() blends two colors in a given color space. Relative color syntax, oklch(from ... l c h / 0.5), takes an existing color and lets you modify its channels, here adding transparency. Both are supported in current versions of all major browsers. Because they reference the semantic tokens, the derived colors automatically adapt to every theme.
Using oklch as the mixing space tends to give more perceptually even results than srgb, especially for darkening and lightening.
Step 7: Theme Sections, Not Just Pages
Because custom properties inherit, you can re-theme any part of the page by overriding tokens on a container. That's useful for inverted sections, promotional banners, or embedded widgets:
<section class="promo" data-theme="dark">
<h2>Upgrade to Pro</h2>
<p>Get unlimited reports and priority support.</p>
<a href="/pricing" class="button">See plans</a>
</section>
[data-theme="dark"] {
color-scheme: dark;
}
[data-theme="light"] {
color-scheme: light;
}
.promo {
background: var(--color-surface);
color: var(--color-text);
padding: 3rem 2rem;
}
If your tokens use light-dark(), this works immediately: light-dark() resolves per element based on its own used color scheme, so the promo section and everything inside it switch to dark values while the rest of the page stays light. The .button inside doesn't need any special styles.
There's one subtlety. An unregistered custom property inherits as a sequence of tokens: any var() references inside it are substituted, but functions like light-dark() aren't evaluated until the variable is used in a real property. So if --color-bg: light-dark(...) is declared only on :root, descendants inherit the unevaluated light-dark() function, and it resolves against each element's own color scheme wherever it's used. That's what makes section theming work. If you declare tokens with plain values in theme-specific selectors instead, you'd need to apply the dark token set to [data-theme="dark"] elements too, not just :root.
Beyond Colors
Themes can control more than color. The same token approach works for typography, spacing, radius, and shadows:
:root {
--font-body: system-ui, sans-serif;
--radius: 12px;
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.06), 0 4px 12px rgb(0 0 0 / 0.08);
}
:root[data-theme="dark"] {
--shadow-card: 0 0 0 1px rgb(255 255 255 / 0.08);
}
.brand-playful {
--font-body: "Nunito", system-ui, sans-serif;
--radius: 24px;
}
Shadows are a good example of something that doesn't translate directly to dark mode. Dark shadows are hard to see on dark backgrounds, so many dark themes replace them with a faint light border.
Common Pitfalls
Using raw colors in components. One hardcoded #ffffff in a component breaks dark mode for that component. A lint rule that flags raw color values outside your token file can catch these.
Forgetting images and illustrations. Logos and diagrams designed for light backgrounds can disappear in dark mode. Use the picture element with a prefers-color-scheme media condition on source to swap images, or design assets that work on both.
Not checking contrast in every theme. A combination that passes contrast requirements in light mode may fail in dark mode. Check each theme separately.
Animating the theme switch everywhere. Adding transition: background-color 300ms to every element for a smooth switch can cause a noticeable performance hit on large pages. If you want a transition, apply it briefly with a class during the switch, or use a view transition.
Conclusion
Custom properties turn theming from a stylesheet management problem into a naming problem. Define semantic tokens once, use them in every component, and give each theme its own values. From there, prefers-color-scheme handles the system default, a data-theme attribute and a few lines of JavaScript handle user choice, and an inline script in the head prevents a flash of the wrong theme.
Modern CSS makes it even simpler: light-dark() collapses both themes into one declaration, color-mix() and relative colors derive states from your tokens, and inheritance lets any section of a page carry its own theme. If you want to go further and animate your theme tokens smoothly, the next step is typed custom properties, which we cover in The CSS @property Rule: Typed and Animatable Variables.


