Implementing Dark Mode Properly in Modern Web Apps
Dark mode is more than inverting colors. Here's how to implement dark mode that looks good, respects user preferences, and doesn't introduce accessibility issues.
Strategic Systems Architect & Enterprise Software Developer
Dark Mode Is a Design System Problem
Adding dark mode to an existing application sounds simple — swap white backgrounds for dark ones, change text to light colors, and ship it. In practice, dark mode touches every visual element in your application: backgrounds, text, borders, shadows, images, icons, form controls, charts, syntax highlighting, third-party embeds, and user-generated content. Treating it as a quick toggle produces a theme that is technically dark but visually broken.
Dark mode is a design system problem because it requires a parallel color system. Every color in your application needs a dark mode equivalent that maintains visual hierarchy, sufficient contrast, and brand consistency. A primary blue that looks great on white may be invisible on dark gray. A subtle light gray border that separates content sections on a white background becomes invisible on a dark background. Shadows that create depth on light backgrounds look unnatural on dark ones.
The approach that works is building your color system on semantic design tokens from the start. Rather than referencing specific colors in your components (background: #ffffff, color: #1a1a2e), reference tokens that map to different values per theme:
:root {
--color-bg-primary: #ffffff;
--color-bg-secondary: #f5f5f5;
--color-text-primary: #1a1a2e;
--color-text-secondary: #6b7280;
--color-border: #e5e7eb;
}
[data-theme="dark"] {
--color-bg-primary: #0f172a;
--color-bg-secondary: #1e293b;
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-border: #334155;
}
Components reference the tokens, and the theme swap changes the token values. This is exactly how Tailwind CSS handles dark mode with its dark: variant prefix — each utility class can have a dark mode counterpart, and the theme is controlled by a class or attribute on the root element.
Respecting User Preferences
Users express their theme preference in two places: their operating system settings and your application's theme toggle. A proper implementation respects both and lets the explicit choice override the system default.
The prefers-color-scheme media query detects the OS preference:
@media (prefers-color-scheme: dark) {
:root {
--color-bg-primary: #0f172a;
/* dark values */
}
}
But media queries alone are insufficient because users need a way to override the system preference within your application. The standard pattern is a three-state toggle: light, dark, and system (auto). When set to system, follow the OS preference. When set to light or dark, apply that theme regardless of the OS setting.
Store the user's explicit preference in localStorage. On page load, check localStorage first — if a preference exists, apply it immediately. If no preference exists, fall back to the system preference via prefers-color-scheme. This logic must run before the page renders to prevent a flash of the wrong theme (FOWT).
For Nuxt or other SSR frameworks, this creates a hydration challenge. The server does not know the user's theme preference, so it renders either light or dark by default. When the client hydrates and reads localStorage, the theme may switch, causing a visible flash. The solution is a small inline script in the <head> that sets the theme attribute before the body renders:
<script>
const theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.setAttribute('data-theme', 'dark');
}
</script>
This script runs synchronously before any rendering, preventing the flash entirely.
Colors, Contrast, and Common Mistakes
The most common dark mode mistake is using pure black (#000000) as the background. Pure black creates maximum contrast with white text, which causes eye strain during extended reading. Material Design's dark theme guidelines recommend dark gray (#121212 or similar) as the surface color, with lighter grays for elevated surfaces. The slight lightness difference creates a visual hierarchy through elevation without the harshness of pure black.
Contrast requirements do not change between themes. WCAG AA requires 4.5:1 contrast for normal text and 3:1 for large text in both light and dark modes. Test every text color against its dark background. Common failures include secondary text that passes contrast on white but fails on dark gray, and link colors that are distinguishable in light mode but blend into the dark background.
Images and media require special attention. Photos generally look fine in dark mode, but images with white or transparent backgrounds — logos, diagrams, screenshots — appear as bright rectangles in a dark interface. Solutions include providing dark-mode variants of key images, adding subtle borders or background padding to images in dark mode, or using mix-blend-mode to blend images with the dark background.
Icons that use currentColor automatically adapt to the theme. Icons with hardcoded colors do not. If your icon system uses SVGs with fixed fill colors, you will need dark mode variants or need to refactor them to use currentColor.
Shadows need rethinking entirely. Drop shadows that create depth on light backgrounds become invisible against dark backgrounds. In dark mode, use lighter background colors for elevated elements (cards, modals, dropdowns) rather than shadows. A card with background: #1e293b on a #0f172a page creates visual hierarchy through contrast, not shadow.
Testing Dark Mode Thoroughly
Dark mode testing is tedious but essential because dark mode issues are often invisible to developers working in light mode. The problems only appear when you actually use the dark theme continuously.
Start by using your own application in dark mode for an entire day. You will immediately find elements that were missed — a white background on a third-party embed, a chart with hardcoded light-mode colors, a tooltip with no dark variant, form inputs with unreadable placeholder text.
Automated visual regression testing catches many dark mode regressions. Tools like Playwright can take screenshots in both themes and compare them against baselines. Configure your testing pipeline to run visual tests in both light and dark mode so that changes to one theme do not inadvertently break the other.
Check transition behavior. When users switch themes, should the change be instant or animated? A subtle transition (transition: background-color 0.2s, color 0.2s) on key elements prevents the jarring flash of a hard switch. But applying transition to all elements via * { transition: all 0.2s } causes performance issues and unintended animation of elements that should not transition.
Test the theme across all pages, not just the homepage. Internal pages, settings screens, error pages, loading states, and empty states are commonly forgotten. Every screen in your application should be auditable in both themes before shipping.