CSS Variables and Theming Tutorial
Use custom properties to build light and dark themes, per-component design tokens, and runtime themes you can toggle with one class.
What you'll learn
- ✓How CSS custom properties cascade
- ✓Defining a color and spacing token system
- ✓Switching themes with data attributes or media queries
- ✓Scoping variables to components
- ✓Animating custom properties
Prerequisites
- •Comfortable with HTML and JavaScript
What and Why
CSS custom properties (also called CSS variables) let you store values once and use them everywhere. Unlike preprocessor variables, they live in the browser at runtime, which means you can change them with JavaScript or with a single class swap. That makes them the foundation for theming, dark mode, and per-component overrides.
Mental Model
A custom property is just a property whose name starts with two dashes. It is inherited like any other property, so a value declared on :root is available across the whole tree. A value declared on a child overrides only that subtree. You read variables with var(--name) and can supply a fallback with a second argument.
:root { --bg: white }
[data-theme="dark"] { --bg: #0f172a }
body { background: var(--bg) }
section.callout { --bg: #fef3c7 } /* local override */
Hands-on Example
Define a starter token set.
:root {
--color-bg: #ffffff;
--color-fg: #0f172a;
--color-muted: #64748b;
--color-accent: #2563eb;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--radius: 0.5rem;
}
body {
background: var(--color-bg);
color: var(--color-fg);
}
Add a dark theme that activates either by user preference or via a manual toggle.
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f172a;
--color-fg: #f8fafc;
--color-muted: #94a3b8;
--color-accent: #60a5fa;
}
}
[data-theme="dark"] {
--color-bg: #0f172a;
--color-fg: #f8fafc;
--color-muted: #94a3b8;
--color-accent: #60a5fa;
}
[data-theme="light"] {
--color-bg: #ffffff;
--color-fg: #0f172a;
}
A small script toggles the manual override.
<button id="toggle">Toggle theme</button>
<script>
const toggle = document.getElementById('toggle');
toggle.addEventListener('click', () => {
const current = document.documentElement.dataset.theme;
document.documentElement.dataset.theme = current === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', document.documentElement.dataset.theme);
});
</script>
Read the saved theme on page load before the body paints to avoid a flash of the wrong colors.
Scope variables for components.
.card {
--card-bg: var(--color-bg);
--card-border: rgb(0 0 0 / 0.1);
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
padding: var(--space-3);
}
.card.featured {
--card-bg: var(--color-accent);
--card-border: transparent;
color: white;
}
The featured card simply overrides the local variables. No additional selector for the background or border is needed.
Animate a variable. With the @property rule the browser knows the variable is a color and can interpolate it smoothly.
@property --glow {
syntax: '<color>';
inherits: false;
initial-value: #94a3b8;
}
.button {
--glow: #94a3b8;
box-shadow: 0 0 0 4px var(--glow);
transition: --glow 200ms ease;
}
.button:hover {
--glow: #2563eb;
}
Common Pitfalls
Custom properties are case sensitive. --Color-bg and --color-bg are different variables. Pick a convention and stick to it; lowercase with dashes is the common choice.
A var() with no fallback evaluates to the initial value of the property when the variable is missing, which usually means it disappears silently. Provide a fallback for critical values: color: var(--color-fg, black).
Reading variables from JavaScript needs getComputedStyle. The inline style attribute does not expose them.
const value = getComputedStyle(document.documentElement)
.getPropertyValue('--color-accent');
You can also set them.
document.documentElement.style.setProperty('--color-accent', '#ec4899');
Flash of unstyled theme on first paint is a common bug. Read the saved preference inline in the document head and set the data attribute before the body renders.
Practical Tips
Group variables by purpose: colors, spacing, radii, shadows, typography. A clear token taxonomy is the first step toward a design system.
Use semantic names (--color-bg) rather than literal names (--gray-100). The semantic layer can map to literal tokens, and the literal tokens can change without rewriting every component.
Define component variables that fall back to global ones. This pattern gives consumers a single override point per component, which is much nicer than passing every color as a prop.
Combine CSS variables with utility frameworks. Tailwind reads custom properties as theme values, so you can keep one source of truth.
Wrap-up
Custom properties are the simplest, most powerful tool CSS gives you for theming. One declaration becomes a knob you can turn from anywhere, and the cascade keeps everything organized. Adopt a token system, add a manual theme override that respects user preference, and your design system grows with you instead of fighting you.
Related articles
- HTML & CSS CSS Variables and Custom Properties
A practical guide to CSS custom properties — declaring them, using var() with fallbacks, scoping by the cascade, building a light/dark theme, and updating them dynamically from JavaScript.
- Tailwind Customizing Tailwind: Theme and Design Tokens
Extend Tailwind's theme with custom colors, spacing, and design tokens that scale across a real product without fighting the framework.
- Tailwind Tailwind Dark Mode Strategies: Class, Media, and CSS Variables
Compare the class-based, media-based, and variable-driven approaches to dark mode in Tailwind, with code and the trade-offs of each.
- HTML & CSS CSS Animations and Keyframes Explained
How CSS keyframe animations actually work: timing functions, fill modes, composition, and the patterns that keep them smooth and predictable.