Theme a Site With CSS Custom Properties
CSS custom properties let a theme change the values a component uses without duplicating its layout rules. Give those values names that describe their job, then change the palette at the root or a smaller wrapper.
Start with a few semantic values:
:root {
--color-page: #ffffff;
--color-text: #222222;
--color-link: #0666c2;
--color-surface: #f2f2f2;
--color-border: #cccccc;
}
--color-link remains a useful name when the link stops being blue. A name tied to its current hex value gives you another thing to rename during a redesign.
Build a theme with CSS custom properties
Use the values in ordinary component rules:
body {
background: var(--color-page);
color: var(--color-text);
font-family: system-ui, sans-serif;
}
a {
color: var(--color-link);
}
.card {
padding: 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
}
A small HTML example is enough to connect the rules:
<article class="card">
<h2>A theme changes values</h2>
<p>The same component can appear on a different surface.</p>
<a href="/blog/">Browse articles</a>
</article>
The custom properties guide explains how the cascade and inheritance connect the definitions to their uses. Ordinary, unregistered custom properties inherit, so a value on a parent can reach its descendants.
Keep the names consistent across the example. Mixing --bg-color, --body-background, and --color-page for the same role makes a short stylesheet harder to follow than it needs to be.
Follow the system preference without a toggle
Add a dark palette after the initial values:
:root {
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--color-page: #121212;
--color-text: #e8e8e8;
--color-link: #8ab4f8;
--color-surface: #242424;
--color-border: #555555;
}
}
The prefers-color-scheme query selects the dark values when that preference applies. The component rules stay unchanged.
The separate color-scheme property tells the browser which schemes the page supports, including browser-rendered interface details. It does not invent a palette for your custom card.
For a small personal site, I prefer this as the starting point. There is no stored preference or button state to keep synchronized before the design actually needs one.
Add an explicit override only when you need it
A manually chosen theme can use an attribute on the root element:
<html lang="en" data-theme="dark">
Place its CSS after the system-preference rule:
:root[data-theme="dark"] {
color-scheme: dark;
--color-page: #121212;
--color-text: #e8e8e8;
--color-link: #8ab4f8;
--color-surface: #242424;
--color-border: #555555;
}
:root[data-theme="light"] {
color-scheme: light;
--color-page: #ffffff;
--color-text: #222222;
--color-link: #0666c2;
--color-surface: #f2f2f2;
--color-border: #cccccc;
}
Set data-theme to choose an explicit palette; remove the attribute to return to the system preference. This example defines the CSS behavior only. A toggle, persistence, and its accessible label are separate interface work.
Avoid transitions in the initial example. Establishing the correct colours before adding motion makes the theme easier to inspect.
Scope brand values to the component’s container
A wrapper can provide a different accent without replacing the page palette:
.brand-playful {
--color-link: #8c2451;
--button-radius: 0.75rem;
}
.brand-formal {
--color-link: #005f73;
--button-radius: 0.25rem;
}
.button-link {
display: inline-block;
padding: 0.5rem 0.75rem;
border: 1px solid currentColor;
border-radius: var(--button-radius, 0.25rem);
color: var(--color-link);
}
Apply one wrapper class around the links using .button-link. The same component consumes different values without a second copy of its CSS.
Background and foreground still need to work together. Changing an accent alone does not establish sufficient contrast for every surface in both schemes.
Spacing, font choices, and shadows can also use custom properties. Keep component-specific values near the component instead of turning every local adjustment into a global token.
Distinguish a missing value from an invalid one
A fallback is useful when a custom property is absent:
.notice {
color: var(--color-notice, #8c2451);
}
It is not a general type check. A declared value such as --color-notice: 12px exists, but substituting it into color produces an invalid value. The fallback in var() does not become a second attempt at interpreting the property.
The var() reference describes substitution and fallback behavior. Registering a property with a type is another choice, covered separately in the CSS @property article.
When a colour disappears, check the spelling, the winning definition, and the element’s parent scope. A theme is easier to maintain when each value has a clear owner and a small number of places where it changes.
Filed under CSS on
Last updated on