Theme a Site with CSS Custom Properties
The useful part of CSS custom properties is not avoiding one repeated hex value. It is giving components a stable vocabulary—background, text, border, accent—whose values can change with the theme.
Define values once, then use them normally
Custom property names begin with two dashes. Values defined on :root are available throughout the document:
:root {
--primary-color: #007bff;
--text-color: #333333;
--body-background: #ffffff;
--base-spacing: 8px;
}
Use a property with var() anywhere the corresponding CSS value is accepted:
body {
background-color: var(--body-background);
color: var(--text-color);
}
button {
background-color: var(--primary-color);
color: white;
padding: var(--base-spacing);
}
.card {
margin-bottom: calc(var(--base-spacing) * 2);
}
Unlike variables in a preprocessor, custom properties participate in the cascade at runtime. A value can be overridden on a parent, and every descendant using it picks up the new value. That is what makes them useful for themes.
Light and dark values, one set of component rules
Start with the light theme in :root:
:root {
--bg-color: #ffffff;
--text-color: #212529;
--link-color: #007bff;
--card-bg: #f8f9fa;
--border-color: #dee2e6;
}
Override only the values that change when a parent has data-theme="dark":
[data-theme="dark"] {
--bg-color: #212529;
--text-color: #f8f9fa;
--link-color: #6cbaff;
--card-bg: #343a40;
--border-color: #495057;
}
The components do not need separate dark-mode selectors:
body {
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
}
a {
color: var(--link-color);
}
.card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
padding: 1rem;
}
Changing the attribute on <html> or <body> switches every component that uses these properties. You can also set the dark values inside @media (prefers-color-scheme: dark) when the theme should follow the operating system.
The same components for different brands
The same idea works for multiple brands. Keep shared structural values at the root:
:root {
--font-family-base: sans-serif;
--spacing-medium: 16px;
}
Then define the brand tokens on wrapper classes:
.theme-playful {
--primary-accent: #ff6b6b;
--secondary-accent: #feca57;
--text-on-primary: #ffffff;
--border-radius: 12px;
}
.theme-corporate {
--primary-accent: #005f73;
--secondary-accent: #0a9396;
--text-on-primary: #ffffff;
--border-radius: 4px;
}
Components consume the tokens without knowing which brand supplied them:
body {
font-family: var(--font-family-base);
}
.button-primary {
background-color: var(--primary-accent);
color: var(--text-on-primary);
border: none;
padding: var(--spacing-medium);
border-radius: var(--border-radius);
}
.highlight-box {
background-color: var(--secondary-accent);
padding: var(--spacing-medium);
border-radius: var(--border-radius);
}
Apply .theme-playful or .theme-corporate to a container and its buttons and highlights update together. No component rules need to be duplicated.
Colors are only the start
I also use custom properties for values that should move together across a design:
- Spacing:
--spacing-small: 4px; --spacing-medium: 8px; --spacing-large: 16px; - Font Sizes:
--font-size-base: 16px; --font-size-large: 1.25rem; - Font Families:
--font-family-heading: 'Georgia', serif; --font-family-body: 'Arial', sans-serif; - Border Radius:
--border-radius-standard: 5px; - Shadows:
--box-shadow-standard: 0 2px 4px rgba(0,0,0,0.1);
Not every value needs a token. A one-off 7px adjustment inside one component is often clearer where it is. Custom properties help most when the name carries meaning or the value needs to change by scope.
A few details that prevent trouble
Put site-wide values in
:root, but keep component-only values on the component.Name properties by purpose.
--color-linksurvives a redesign better than--blue-1.Use prefixes such as
--color-,--font-, and--space-when the token list grows.Add a fallback when an unset value would otherwise invalidate the declaration.
color: var(--link-color, blue);
Basic custom properties have broad support in modern browsers. The main risk is not compatibility; it is creating an enormous global token list that nobody understands.
For theming, I prefer a small set of semantic values and ordinary component CSS. The theme changes the values, while the components stay boring. That is exactly the separation I want.