Dynamic Theme Switching with CSS Vars and System Preferences
#css
#theming
#webdev
Introduction
Dynamic theme switching is increasingly essential for modern web experiences. In this post, we’ll explore a practical pattern that uses CSS custom properties (variables) and the system’s color scheme preference to automatically switch themes, with an optional manual override. The approach stays lightweight, accessible, and resilient to varying user environments.
Core idea: CSS vars + system preference
The core idea is to define a set of color tokens as CSS variables and drive their values through two mechanisms:
- System preference via the prefers-color-scheme media feature.
- Optional, explicit user overrides via a data attribute on the root element.
This keeps styling centralized and allows themes to “cascade” naturally without duplicating rules for every component.
System preferences and prefers-color-scheme
The prefers-color-scheme media feature lets you detect whether the user prefers a light or dark theme. You can hook this up with CSS variables to automatically apply a theme without JavaScript. Example pattern:
- Define default tokens for light mode.
- Override tokens inside a dark media query.
- Allow an explicit data-theme override to supersede the system when needed.
Strategy: declarative defaults with an optional imperative override
- Default (no override): system preference governs the look via media queries.
- System override (via JS): remove any explicit override to re-synchronize with the system.
- Manual override (via data-theme): apply a specific theme (light or dark) regardless of system.
This separation keeps your code predictable and accessible. It also makes it straightforward to add more tokens later (e.g., accents, shadows, borders) without changing component code.
Example: CSS variables and system preference
Here’s a compact CSS setup that demonstrates the concept. It uses tokens for background, text, surface, and an accent color.
/* CSS: theme tokens with system and manual overrides */
:root {
--bg: #ffffff;
--fg: #0b1020;
--surface: #ffffff;
--card: #ffffff;
--muted: #6b7280;
--brand: #4f46e5;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b1020;
--fg: #e5e7eb;
--surface: #141a30;
--card: #111629;
--muted: #93a3b8;
--brand: #a78bfa;
}
}
/* Manual override: system remains the default when this is not set,
but setting data-theme overrides tokens. */
[data-theme="light"] {
--bg: #ffffff;
--fg: #0b1020;
--surface: #ffffff;
--card: #ffffff;
--muted: #6b7280;
--brand: #4f46e5;
}
[data-theme="dark"] {
--bg: #0b1020;
--fg: #e5e7eb;
--surface: #141a30;
--card: #111629;
--muted: #93a3b8;
--brand: #a78bfa;
}
/* Page scaffolding that consumes the tokens */
html, body {
background: var(--bg);
color: var(--fg);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Inter, Arial, sans-serif;
line-height: 1.5;
margin: 0;
}
.header {
background: var(--surface);
color: var(--fg);
padding: 1rem;
border-bottom: 1px solid rgba(0,0,0,.08);
}
.panel {
background: var(--card);
color: var(--fg);
padding: 1rem;
border-radius: 12px;
box-shadow: 0 4px 14px rgba(0,0,0,.08);
}
.brand {
color: var(--brand);
}
Practical UI: a small theme switcher
To allow users to override the system preference, you can provide a simple control. The following HTML and JavaScript show a lightweight “System / Light / Dark” picker. When System is selected, any explicit data-theme attribute is removed so the system preference takes effect again.
<div class="theme-controls" aria-label="Theme controls">
<label for="theme-select">Theme:</label>
<select id="theme-select" aria-label="Theme">
<option value="system" selected>System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
<div class="panel" role="region" aria-label="Example content">
This panel demonstrates theming with CSS variables. The background and text colors adapt to the chosen theme.
</div>
<script>
(function() {
const select = document.getElementById('theme-select');
const root = document.documentElement;
// Initialize based on stored preference, if any
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') {
root.setAttribute('data-theme', stored);
select.value = stored;
} else {
// System mode: nothing special to do; CSS @media handles it
select.value = 'system';
}
// React to user changes
select.addEventListener('change', (e) => {
const value = e.target.value;
if (value === 'system') {
root.removeAttribute('data-theme');
localStorage.removeItem('theme');
} else {
root.setAttribute('data-theme', value);
localStorage.setItem('theme', value);
}
});
// Optional: keep in sync if system preference changes and we're in system mode
// (No JS needed for visual changes; this block is here for completeness)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (!localStorage.getItem('theme')) {
// In system mode; the CSS media query handles the change automatically
}
});
})();
</script>
Accessibility considerations
- Maintain sufficient contrast in both themes. Test text against background colors to ensure a minimum contrast ratio (typically 4.5:1 for body text).
- Ensure focus states remain visible. If you use custom components, style focus outlines with your token colors (e.g., var(—brand)).
- Respect users’ reduced motion preferences where applicable, and avoid abrupt color shifts during interactions.
Testing and best practices
- Test across light and dark system preferences, including the transition between them.
- Verify affected components (buttons, cards, inputs) render correctly in both themes.
- Consider adding a11y tests for color contrast in both modes.
- Keep tokens expressive and shareable across components to minimize duplication.
Full-page starter (copy-paste-ready)
If you want a quick starter, combine the CSS from the CSS snippet with the HTML and JS above. The pattern is intentionally small and portable: a few tokens, a media query for system preference, and an optional data-theme override for user control.
Conclusion
Using CSS variables alongside the prefers-color-scheme media feature provides a robust, lightweight approach to dynamic theming. It respects users’ system preferences by default while offering a straightforward override path for those who want a custom look. As you expand your design system, you can extend the token set and maintain a consistent theme experience across your entire site.