What is Dark Mode with CSS Custom Properties?
Dark mode is a color scheme that uses light text and UI elements on a dark background. Implementing it with CSS custom properties (also called CSS variables) gives you a clean, maintainable way to switch themes without duplicating stylesheets or overriding dozens of rules. You define a palette of reusable color values once, then toggle their definitions when dark mode is active. The result is a smooth, scalable theme system that works across your entire stylesheet.
Why It Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Building dark mode with custom properties offers several important advantages:
- Reduced eye strain – Dark interfaces are easier to read in low-light environments, making your application more comfortable for users.
- Battery savings – On OLED screens, dark pixels consume significantly less power, extending device battery life.
- Accessibility & user preference – Many users prefer dark themes for readability or personal taste. Respecting system-level preferences shows thoughtful design.
- Maintainability – Custom properties centralize your color definitions. Changing a theme is as simple as updating a few variables, instead of hunting through hundreds of selectors.
- No extra HTTP requests – Unlike loading separate stylesheets, the variables approach keeps everything in one CSS file, reducing network overhead.
How to Implement Dark Mode Step by Step
1. Define Your Color Palette as Custom Properties
Start by defining a set of custom properties on the :root pseudo-class.
These represent your light theme (the default). Use descriptive names
like --bg, --text, --primary, etc., so you can reuse
them consistently throughout your CSS.
:root {
/* Light theme (default) */
--bg: #ffffff;
--bg-secondary: #f5f5f5;
--text: #1a1a1a;
--text-secondary: #555555;
--primary: #0066cc;
--border: #e0e0e0;
--shadow: rgba(0, 0, 0, 0.1);
}
2. Create a Dark Theme Variant
Next, define a [data-theme="dark"] selector (or a class like
.dark-mode) on the root element. Inside this rule, reassign the same
custom properties to darker values. This is where the magic happens: you swap
the palette without touching any component styles.
:root[data-theme="dark"] {
/* Dark theme overrides */
--bg: #121212;
--bg-secondary: #1e1e1e;
--text: #e0e0e0;
--text-secondary: #a0a0a0;
--primary: #3399ff;
--border: #333333;
--shadow: rgba(0, 0, 0, 0.5);
}
If you prefer a class-based approach, you can use:
:root.dark-mode {
/* same dark values */
}
3. Apply the Custom Properties Throughout Your Styles
Use the custom properties everywhere you would normally hardcode colors. This ties your entire design to the variables, so a theme switch instantly updates everything.
body {
background-color: var(--bg);
color: var(--text);
font-family: system-ui, sans-serif;
transition: background-color 0.3s, color 0.3s;
}
.card {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
box-shadow: 0 2px 8px var(--shadow);
padding: 1.5rem;
border-radius: 0.75rem;
}
.card h2 {
color: var(--text);
}
.card p {
color: var(--text-secondary);
}
button {
background-color: var(--primary);
color: #ffffff;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
cursor: pointer;
}
4. Add a Theme Toggle with JavaScript
Create a button that switches the data-theme attribute on
the <html> element. Store the user's choice in
localStorage so it persists across page reloads.
<button id="theme-toggle">Toggle Dark Mode</button>
<script>
const toggle = document.getElementById('theme-toggle');
const root = document.documentElement;
// Apply saved theme on load
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
root.setAttribute('data-theme', savedTheme);
}
toggle.addEventListener('click', () => {
const current = root.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
});
</script>
For a class-based toggle, you can use classList.toggle('dark-mode')
and store a string flag accordingly.
5. Respect System Preferences with prefers-color-scheme
Many users configure a system-wide dark or light appearance. You can detect
this with the prefers-color-scheme media query and set your
default theme accordingly. Combine it with the saved user choice to create
a seamless experience.
/* Default light theme (as above) */
:root {
--bg: #ffffff;
--text: #1a1a1a;
/* ... */
}
/* Automatic dark if system prefers dark and no manual override */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #121212;
--text: #e0e0e0;
/* ... other dark values */
}
}
In your JavaScript, adjust the logic to only save a theme when the user explicitly clicks the toggle. If no saved theme exists, let the media query decide.
// Check saved theme first; if none, respect system preference
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
root.setAttribute('data-theme', savedTheme);
} else {
// No manual override, media query handles it automatically
// Optionally set data-theme based on matchMedia result
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
root.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
}
// Toggle still works the same way
toggle.addEventListener('click', () => {
const current = root.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
});
Best Practices for Dark Mode with Custom Properties
-
Name variables by purpose, not by color – Use
--bg-primaryinstead of--white. This keeps them meaningful when the value changes in dark mode. -
Add smooth transitions – A short
transitiononbackground-colorandcolorprevents a jarring flash when toggling themes. -
Consider contrast and readability – Dark mode text
should still meet WCAG contrast guidelines. Avoid pure black
(
#000) and pure white text; off-white on dark gray is more comfortable. -
Use
data-*attributes for state – Adata-themeattribute is easy to read and doesn't interfere with other classes. It also works well with CSS selectors. -
Persist user preference – Save the choice in
localStorageand restore it on page load. This respects the user's explicit selection across visits. -
Fall back gracefully – If a browser doesn't support
custom properties (very rare now), provide a hardcoded fallback inside
var(), likecolor: var(--text, #1a1a1a);. - Test with real content – Images, shadows, and borders behave differently in dark mode. Make sure illustrations and icons are still visible, and adjust shadows to be darker and more subtle.
Conclusion
Implementing dark mode with CSS custom properties turns a complex theming
challenge into a straightforward, maintainable system. By defining a palette
of variables on :root, overriding them with a data-theme
selector, and sprinkling those variables throughout your styles, you give users
a seamless way to switch between light and dark appearances. Add a simple
JavaScript toggle and a prefers-color-scheme media query, and
you've built an accessible, user-friendly experience that respects both
explicit choices and system settings. This approach scales beautifully from
small personal projects to large production applications.