Dark mode has gone from a nice-to-have to an expected feature. CSS variables make implementing dark mode straightforward, maintainable, and performant. Here's how to do it right.
CSS-Only Dark Mode with prefers-color-scheme
The simplest approach uses the prefers-color-scheme media query to automatically match the user's system preference:
:root {
--color-background: #ffffff;
--color-foreground: #0f172a;
--color-primary: #3b82f6;
--color-muted: #f1f5f9;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: #0f172a;
--color-foreground: #f8fafc;
--color-primary: #60a5fa;
--color-muted: #1e293b;
}
}This approach is zero-JavaScript and respects user preferences automatically. The downside is users can't override the system setting on your site.
Toggle-Based Dark Mode (Class Strategy)
For user-controllable dark mode, use a class on the root element:
:root {
--color-background: #ffffff;
--color-foreground: #0f172a;
}
:root.dark {
--color-background: #0f172a;
--color-foreground: #f8fafc;
}Toggle the class with JavaScript:
// Toggle dark mode
document.documentElement.classList.toggle('dark');
// Set specific mode
document.documentElement.classList.add('dark');
document.documentElement.classList.remove('dark');
// Persist preference
localStorage.setItem('theme', 'dark');Combining Both Approaches
The best UX combines system preference with user override:
// On page load
function initTheme() {
const stored = localStorage.getItem('theme');
if (stored) {
// User has explicit preference
document.documentElement.classList.toggle('dark', stored === 'dark');
} else {
// Fall back to system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', prefersDark);
}
}
// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
document.documentElement.classList.toggle('dark', e.matches);
}
});The CSS Variable Approach
CSS variables are ideal for dark mode because:
- Single source of truth: Change colors in one place
- No specificity wars: Variables cascade naturally
- Works everywhere: Any CSS that uses the variables automatically adapts
- Transition support: Smooth theme transitions with CSS
:root {
--transition-theme: background-color 0.2s, color 0.2s;
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
transition: var(--transition-theme);
}Tailwind CSS Dark Mode Setup
Tailwind supports dark mode out of the box. Configure it in tailwind.config.js:
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class', // or 'media' for system preference only
theme: {
extend: {
colors: {
background: 'var(--color-background)',
foreground: 'var(--color-foreground)',
primary: 'var(--color-primary)',
},
},
},
}Then use dark: variants in your HTML:
<div class="bg-white dark:bg-slate-900">
<p class="text-slate-900 dark:text-white">
This text adapts to dark mode
</p>
</div>Or better yet, use CSS variables so you don't need dark: variants:
<div class="bg-[var(--color-background)]">
<p class="text-[var(--color-foreground)]">
This automatically adapts—no dark: needed
</p>
</div>Avoiding Flash of Unstyled Content (FOUC)
The biggest dark mode pitfall is a flash of light mode before JavaScript runs. Fix this with a blocking script in your <head>:
<head>
<script>
// This runs before any content renders
(function() {
const theme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (!theme && prefersDark)) {
document.documentElement.classList.add('dark');
}
})();
</script>
</head>In Next.js, use a similar approach with dangerouslySetInnerHTML or the next-themes library.
Common Pitfalls
1. Forgetting Image Contrast
Images designed for light backgrounds may look wrong on dark. Consider:
- Using transparent PNGs with appropriate colors
- Providing dark mode variants of logos
- Adding subtle backgrounds behind images
2. Pure Black Backgrounds
Pure black (#000000) can be harsh and cause "halation" on OLED screens. Use dark grays like #0f172a or #1a1a1a instead.
3. Insufficient Contrast
Test your dark mode colors for WCAG contrast ratios. Text should have at least 4.5:1 contrast with the background.
4. Forgetting Third-Party Components
Libraries like maps, embeds, or UI components may not respect your dark mode. Plan for overrides or dark-compatible alternatives.
How PickCSS Generates Light + Dark Themes
PickCSS exports both light and dark mode tokens automatically. Your theme.css file includes:
:root {
/* Light mode (default) */
--color-background: #ffffff;
--color-foreground: #0f172a;
--color-primary: #3b82f6;
/* ... 70+ variables */
}
:root.dark {
/* Dark mode overrides */
--color-background: #0f172a;
--color-foreground: #f8fafc;
--color-primary: #60a5fa;
/* ... matching dark variants */
}Dark mode colors are automatically calculated to maintain proper contrast and visual harmony.
Getting Started
Want properly balanced light and dark themes? PickCSS generates both with a single design session—no manual color picking required.
Create your theme and get dark mode for free.