Dark Mode in Modern Web Development
To implement dark mode with proper color management, define your colors as semantic CSS custom properties (like --bg-primary and --text-primary) on :root, override them inside a [data-theme="dark"] selector, and control that attribute with a small inline <head> script that reads localStorage first and falls back to the prefers-color-scheme media query. This one pattern gives you three things at once: automatic operating-system detection, a manual toggle that can override the OS, and a single place to edit every color. It is a genuinely more robust setup than inverting colors or maintaining two separate stylesheets.
That is the summary an AI Overview will give you. Here is what a summary can't show you: why the naive versions of this break, exactly where the white flash comes from, which contrast numbers you have to hit in each theme, and how the cascade actually resolves a color at paint time. The tables and diagrams below map those out. Dark mode has become an important feature because users want it for reduced eye strain, better battery life on OLED screens, and aesthetic reasons, but implementing it well requires more than flipping colors, you need to manage them deliberately to preserve readability, contrast, and brand identity across both themes.
Three ways to switch themes, compared
There is no single "dark mode API." You are choosing between three implementation strategies, and the right one depends on whether you need a user-facing toggle.
| Strategy | How it detects theme | User can override OS? | Flash on load? | Best for |
|---|---|---|---|---|
prefers-color-scheme only | OS/browser setting via media query | No — media queries are read-only from JS | No (CSS-only) | Content sites that just want to honor the system setting |
Class / data-theme toggle only | JavaScript sets an attribute on <html> | Yes | Yes, unless the script is blocking in <head> | Apps where the toggle is the whole point and you ignore the OS |
| Both (recommended) | Default to prefers-color-scheme, let saved data-theme win | Yes | Only if the inline script is missing | Almost every real site — automatic and overridable |
Which should you use? Use both. Start from the system preference so first-time visitors get the right theme with zero interaction, then let an explicit data-theme attribute (saved to localStorage) override it when a user clicks your toggle. The rest of this guide builds exactly that setup.
Why Implement Dark Mode
Before diving into implementation, understand why dark mode matters.
User preference is significant. Studies show that many users prefer dark mode, especially for evening use or in low-light environments. Providing dark mode aligns with user expectations.
Accessibility benefits: Dark mode can improve readability for people with light sensitivity or certain visual conditions. Reduced brightness on OLED screens also helps users with light-sensitive conditions.
Device battery life: OLED screens consume less power displaying dark colors (because each pixel produces its own light). Dark mode can meaningfully extend battery life on mobile devices with OLED screens.
Brand consistency: Many brands now have dark mode versions of their websites and applications. Providing dark mode helps maintain consistency across touchpoints.
Understanding Prefers-Color-Scheme
The CSS media query prefers-color-scheme allows you to detect whether the user prefers light or dark mode based on their operating system or browser settings.
@media (prefers-color-scheme: light) {
/* Styles for light mode */
}
@media (prefers-color-scheme: dark) {
/* Styles for dark mode */
}
The user's preference typically comes from their operating system settings (light mode or dark mode in Windows, macOS, or iOS) or browser settings.
Always provide default styles (assuming light mode) and then override them in the dark mode media query. This ensures compatibility with older browsers that don't support prefers-color-scheme.
Color Management Strategies for Dark Mode
CSS Custom Properties (Variables)
The most effective approach to managing colors for multiple themes is using CSS custom properties.
Define color variables that apply to both light and dark modes:
:root {
/* Light mode (default) */
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--border-color: #dddddd;
--accent-color: #007bff;
}
@media (prefers-color-scheme: dark) {
:root {
/* Dark mode */
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--text-primary: #ffffff;
--text-secondary: #cccccc;
--border-color: #444444;
--accent-color: #4da6ff;
}
}
Then use these variables throughout your stylesheet:
body {
background-color: var(--bg-primary);
color: var(--text-primary);
}
.card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
}
button {
background-color: var(--accent-color);
color: var(--text-primary);
}
This approach provides several benefits. All color changes are centralized in one place. Adding a new color only requires defining it in both light and dark mode sections. Updating a color affects all elements using that variable. Themes can be easily extended with additional variables.
HSL for Flexible Color Adjustment
Using HSL color format makes it easier to create dark mode variants that maintain color relationships.
For example, in light mode you might use:
--accent-color: hsl(210, 100%, 50%);
In dark mode, you might adjust the lightness:
--accent-color: hsl(210, 100%, 65%);
This keeps the same hue and saturation while adjusting brightness for dark mode. The color remains visually related to the light mode color but is appropriately bright for dark backgrounds.
Implementing Dark Mode Step by Step
Step 1: Define Your Color Palette
Start by defining complete color palettes for both light and dark modes. Consider:
Background colors (primary and secondary) Text colors (primary, secondary, and disabled) Border and divider colors Interactive element colors (buttons, links) Accent and highlight colors Status colors (success, error, warning, info)
Ensure all text colors meet WCAG contrast requirements against their background colors in both themes.
Step 2: Create CSS Variables
Structure your CSS variables hierarchically:
:root {
/* Base colors */
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-900: #111827;
--color-blue-500: #3b82f6;
--color-blue-600: #2563eb;
/* Semantic colors */
--bg-primary: var(--color-gray-50);
--bg-secondary: #ffffff;
--text-primary: var(--color-gray-900);
--text-secondary: #666666;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: var(--color-gray-900);
--bg-secondary: #1f2937;
--text-primary: var(--color-gray-50);
--text-secondary: #d1d5db;
}
}
Step 3: Apply Variables Throughout Stylesheets
Use variables consistently in all stylesheets:
body {
background-color: var(--bg-primary);
color: var(--text-primary);
}
a {
color: var(--color-blue-500);
}
a:hover {
color: var(--color-blue-600);
}
.button-primary {
background-color: var(--color-blue-500);
color: white;
}
.button-primary:hover {
background-color: var(--color-blue-600);
}
.card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
}
input {
background-color: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
input::placeholder {
color: var(--text-secondary);
}
Step 4: Test Thoroughly
Test your implementation across different browsers and devices:
Verify that both light and dark modes render correctly. Test in actual dark mode on your OS (not just the browser's dark mode simulation). Verify contrast requirements in both themes. Check that images and graphics look appropriate in both themes. Test interactive elements (hover, focus, active states) in both themes. Test with different browser zoom levels. Test with Windows High Contrast Mode enabled.
Step 5: Handle Images and Media
Some content might need different versions for light and dark modes.
For images, consider using CSS filter properties:
@media (prefers-color-scheme: dark) {
img.logo {
filter: brightness(0) invert(1);
}
}
Or provide different images:
<picture>
<source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)">
<img src="logo-light.svg" alt="Company Logo">
</picture>
For icons and SVGs, use CSS filters or provide alternate versions.
Allowing User Override
Beyond system preference, many users appreciate the ability to manually toggle between light and dark modes.
You can detect the user's preference and allow overriding it:
// Check if user has a saved preference
const savedMode = localStorage.getItem('color-scheme');
// Check system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Set mode: user preference takes precedence over system preference
const mode = savedMode || (prefersDark ? 'dark' : 'light');
// Apply mode to document
document.documentElement.setAttribute('data-color-scheme', mode);
// Toggle button handler
document.getElementById('theme-toggle').addEventListener('click', () => {
const currentMode = document.documentElement.getAttribute('data-color-scheme');
const newMode = currentMode === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-color-scheme', newMode);
localStorage.setItem('color-scheme', newMode);
});
Critical detail most tutorials skip: the code above runs after your CSS has loaded, so the browser paints the default light theme first and then repaints dark — the notorious "flash of unstyled content" (FOUC). To eliminate it, put a tiny blocking inline script in the <head>, before any stylesheet, that sets the attribute synchronously before first paint:
<head>
<!-- Runs before CSS paints — no flash -->
<script>
(function () {
var saved = localStorage.getItem('color-scheme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var mode = saved || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-color-scheme', mode);
})();
</script>
<link rel="stylesheet" href="/styles.css">
</head>
This is the one part of a dark-mode implementation that genuinely cannot be done in CSS alone, and skipping it is the most common reason a toggle "works but flickers."
Then update CSS to respect the data attribute:
/* Default to system preference */
/* Light mode styles here */
@media (prefers-color-scheme: dark) {
/* Dark mode styles here */
}
/* Allow user override */
[data-color-scheme="light"] {
--bg-primary: #ffffff;
--text-primary: #1a1a1a;
/* light mode colors */
}
[data-color-scheme="dark"] {
--bg-primary: #1a1a1a;
--text-primary: #ffffff;
/* dark mode colors */
}
Color Accessibility in Both Modes
Ensure colors meet accessibility requirements in both light and dark modes:
Check contrast ratios in both themes. Test with color blindness simulators in both themes. Avoid relying on color alone to convey information in either theme. Ensure interactive elements are clearly distinct in both themes. Test with different vision types in both themes.
Dark Mode Troubleshooting: Symptom to Cause to Fix
When dark mode misbehaves, the bug is almost always one of a handful of predictable failures. Match the symptom, not the guess.
| Symptom | Root cause | Fix |
|---|---|---|
| White flash for a split second on load | Theme JS runs after CSS paints | Move a blocking theme script into <head> before any stylesheet |
| Toggle button does nothing on some pages | Attribute set on <body>, CSS targets <html> (or vice versa) | Set and read data-theme on the same element — use documentElement (<html>) |
| Text unreadable in dark mode | Pure white text on dark grey causes halation; contrast too low | Use off-white (#e0e0e0) text and verify 4.5:1 contrast per theme |
| Logo disappears on dark background | White logo on transparent PNG blends into dark bg | Swap via <picture> + prefers-color-scheme, or filter: invert(1) on monochrome marks |
| Colors right on refresh, wrong after toggle | Toggle updates the attribute but not localStorage | Persist the choice: localStorage.setItem('color-scheme', newMode) on every toggle |
| Some components stay light | Hard-coded hex values instead of variables | Replace literal colors with var(--…) semantic tokens everywhere |
| Jarring switch on some elements | No transition, or transitions on too many properties | Add a short transition on background-color/color; respect prefers-reduced-motion |
Why semantic variables (not raw colors) make this manageable
The reason CSS custom properties beat two hand-maintained stylesheets is one level of indirection: your components reference a role (--bg-primary), and each theme decides what color that role maps to. Change the theme, and every element updates with zero component edits.
Common Pitfalls to Avoid
Don't simply invert colors. Inverted light mode colors often don't create acceptable dark mode colors. Colors need to be carefully selected for each theme.
Don't ignore images. Images that look good on light backgrounds might need adjustment for dark backgrounds.
Don't forget about transitions. Users switching themes might experience jarring changes. Consider using CSS transitions:
:root {
transition: background-color 0.3s ease, color 0.3s ease;
}
Don't forget intermediate tones. When creating dark mode, ensure you have appropriate colors for disabled states, hover states, and other variants.
Don't assume everyone wants dark mode. Some users find dark mode harder to read. Respecting the system preference means respecting the user's choice.
Performance Considerations
Dark mode implementation should not significantly impact performance:
Use CSS custom properties efficiently. They have minimal performance impact. Avoid excessive media queries. Structure them logically. Preload dark mode images/assets if users frequently switch themes. Use CSS transitions sparingly to avoid performance issues during switching.
Implementing dark mode effectively requires thoughtful color management, careful testing, and attention to accessibility. By using CSS variables to manage colors systematically and testing thoroughly in both light and dark modes, you can provide users with a high-quality dark mode experience that maintains your brand identity and ensures readability across all viewing conditions.