Dark mode has moved from a developer novelty to a baseline user expectation. iOS, Android, macOS, and Windows all surface it as a system-level setting, and a significant portion of users keep their operating system in dark mode permanently. Adding dark mode support to a web application respects that preference, improves usability in low-light environments, reduces eye strain during extended use, and on OLED displays can meaningfully reduce battery consumption.
This guide covers the complete implementation: CSS custom properties as the color abstraction layer, prefers-color-scheme for automatic system sync, a manual user toggle with localStorage persistence, image handling edge cases, contrast requirements, and the common pitfalls that catch teams after the initial implementation looks correct.
pexels: smartphone hand close screen
The Foundation: CSS Custom Properties
The cleanest architecture for dark mode uses CSS custom properties (also called CSS variables) to define all color values in a single location. This avoids the maintenance nightmare of writing element-level color overrides in a separate dark mode stylesheet.
Define your color tokens on the :root element:
:root {
--color-background: #ffffff;
--color-surface: #f5f5f5;
--color-text-primary: #1a1a1a;
--color-text-secondary: #6b7280;
--color-border: #e5e7eb;
--color-accent: #3b82f6;
}
Then define the dark mode overrides under a [data-theme="dark"] attribute selector on the <html> element. JavaScript toggles the theme by setting this attribute, and the entire page updates instantly:
[data-theme="dark"] {
--color-background: #0f172a;
--color-surface: #1e293b;
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-border: #334155;
--color-accent: #60a5fa;
}
With this in place, every element that uses var(--color-background) rather than a hard-coded hex value switches instantly when the attribute changes. No cascade overrides, no specificity fights, no duplicate rule sets - just a token swap at the root.
The key discipline is consistency: every color value across your CSS must go through a custom property. Hard-coded hex values scattered across component stylesheets are the primary cause of missed overrides where an element stays light in an otherwise dark interface.
Detecting System Preference
The prefers-color-scheme CSS media feature reads the user's operating system color scheme preference. You can use it in CSS directly or through the JavaScript matchMedia API.
In CSS:
@media (prefers-color-scheme: dark) {
:root {
--color-background: #0f172a;
--color-surface: #1e293b;
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-border: #334155;
--color-accent: #60a5fa;
}
}
Browser support for prefers-color-scheme covers all modern browsers. The CSS-only approach applies the theme immediately on page parse without any JavaScript execution delay, which eliminates the flash of the wrong theme that a JavaScript-first approach can produce if the script loads after the initial paint.
The limitation of pure CSS is that it offers no mechanism for a manual user toggle. The theme is determined entirely by the OS setting. For most applications you want both behaviors: respect the system preference by default, and allow users to choose differently.
pexels: terminal screen close monospace
Adding a Manual Toggle With localStorage
Combine the CSS custom property architecture with a JavaScript-controlled data-theme attribute. On page load, check for a stored user preference and fall back to the system preference when none is stored:
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
}
function getPreferredTheme() {
const stored = localStorage.getItem('theme');
if (stored) return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
// Apply immediately -- run before any paint to avoid theme flash
applyTheme(getPreferredTheme());
// Toggle button handler
document.getElementById('theme-toggle').addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', next);
applyTheme(next);
});
The critical implementation detail is running applyTheme(getPreferredTheme()) as early as possible in the document lifecycle - specifically in a <script> tag placed in the <head> before stylesheets finish parsing. This is the only reliable way to prevent the flash of the wrong theme on initial load. Script placement in the <head> is an intentional exception to the general rule of deferring scripts.
Use the window.matchMedia API to listen for OS-level preference changes mid-session and update the page when the user switches their OS setting. But only apply the change if the user has not already stored an explicit preference, since a stored preference should always take priority:
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
applyTheme(e.matches ? 'dark' : 'light');
}
});
Handling Images and SVGs
Images require careful consideration when dark backgrounds are involved. Light-background images - diagrams, screenshots with white backgrounds, logos with light fills - create jarring contrast against dark surfaces and need explicit handling.
CSS filter for blanket adjustment. A subtle brightness and contrast filter softens images that are technically correct but visually harsh against a dark background without significantly degrading image quality:
[data-theme="dark"] img:not([data-no-filter]) {
filter: brightness(0.9) contrast(1.05);
}
The data-no-filter attribute exempts specific images - photos and illustrations that should not be adjusted - from the blanket rule.
<picture> for art-directed swaps. When the difference between light and dark variants is significant enough to warrant separate assets - a logo with a light fill swapped for one with a light stroke, or a diagram with a dark background variant - use the <picture> element:
<picture>
<source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)">
<img src="logo-light.svg" alt="Company logo">
</picture>
SVG with currentColor. For inline SVGs and icon sets, use currentColor for fills and strokes rather than hard-coded hex values. Icons using currentColor inherit the text color of their parent element and respond automatically to your CSS token changes without requiring separate dark mode rules.
pexels: color swatches paint chips
Contrast and Accessibility
The WCAG minimum contrast ratio for normal text is 4.5:1 against its background. This requirement applies equally in dark mode. Color combinations that look good visually can still fail the accessibility threshold if the gap between lightness values is too small.
Avoid the extremes of pure black (#000000) and pure white (#ffffff). Very high contrast between foreground and background can produce halation on OLED displays - a visual artifact where bright text appears to glow into the surrounding dark area at its edges. A background around #0f172a and text around #f1f5f9 provides strong contrast without reaching the halation threshold.
Secondary text elements, placeholder text, and disabled state text all require separate contrast checks since they are intentionally muted and can drop below accessible thresholds if the token values are not validated explicitly. Run your full dark mode palette through a contrast checker before shipping.
"Dark mode implementation is one of those details that separates polished applications from rough ones. The CSS custom properties approach makes it maintainable at scale - you define the token layer once and the rest of the codebase picks it up automatically. Where teams get into trouble is skipping the token abstraction and adding one-off overrides instead." - Dennis Traina, founder of 137Foundry (view services)
Third-Party Embeds
Maps, payment widgets, chat tools, and analytics dashboards embedded as iframes sit outside your CSS scope. Dark mode cannot reach inside them through stylesheets. For embeds that offer a theme parameter - many map SDKs, payment forms, and chat widgets do - pass the dark theme through the embed configuration rather than through CSS.
For embeds with no dark theme option, filter: invert(1) hue-rotate(180deg) applied to the iframe in dark mode is an approximation that often produces tolerable results. It degrades images and brand colors noticeably, so it is a last resort rather than a default strategy.
Testing Dark Mode Thoroughly
Systematic testing catches overrides that visual inspection misses.
Surface elements. Cards, panels, modals, and drawers that share the page background color become invisible in dark mode if the surface token is not implemented. Test every distinct surface in the UI.
Form controls. Browser-default styles for input, select, textarea, and button elements often resist custom color overrides. Explicit styling for every interactive control is typically required in dark mode.
Focus indicators. Keyboard focus outlines - particularly blue outlines - frequently disappear against dark backgrounds. Verify that every focusable element has a visible focus state in both themes.
Component libraries. Third-party UI components included in the project may have their own internal color logic that requires separate dark mode configuration outside your token system.
Print stylesheets. Dark mode background colors consume ink aggressively when users print. A @media print rule that resets to white background and black text prevents surprise high-ink prints.
The 137Foundry web development team runs dark mode through this checklist as part of front-end quality reviews for client applications. The print issue in particular is a consistent catch that teams do not discover until someone attempts to print a page during a client presentation.
pexels: phone home icons grid
Common Pitfalls
Flash on load. A transition: background-color 0.3s ease rule applied globally creates a visible color flash on page load because the transition fires when the theme is first applied. Apply transitions only via a class added to <html> after the initial paint completes, so theme changes animate but the initial load does not.
Inline styles bypassing the token system. JavaScript-generated inline styles with hard-coded hex values are invisible to CSS selectors and will not respond to theme changes. Replace dynamic style generation with CSS class toggles wherever possible.
Dark mode gaps in new features. Without a review gate, dark mode support tends to slip during fast-moving feature development. The simplest safeguard is treating any color value that does not map to a custom property in your token list as a blocking issue before merge.
Specificity hacks with !important. Using !important in dark mode overrides is a symptom of a cascade conflict in the base styles, not a dark mode problem. Fix the cascade specificity rather than patching around it.
Bringing dark mode into an existing application is the right moment to audit color usage across the full codebase. Teams that skip the audit in favor of targeted overrides spend months chasing the last edge cases. The services hub at 137Foundry covers front-end architecture and code review for teams adding cross-cutting system features like this one - where getting the foundation right the first time costs far less than fixing accumulated override debt later.
Dark mode done right is invisible to users who do not care about it and essential to users who do. The CSS custom properties architecture described here scales cleanly from a single-page application to a large multi-team codebase, and building it correctly from the beginning costs far less than retrofitting it after the fact.