Web Design

How do I implement dark mode with color management?

Learn how to implement dark mode on your website while maintaining proper color management, accessibility, and a consistent user experience across light and dark themes.

By Inventive HQ Team

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.

StrategyHow it detects themeUser can override OS?Flash on load?Best for
prefers-color-scheme onlyOS/browser setting via media queryNo — media queries are read-only from JSNo (CSS-only)Content sites that just want to honor the system setting
Class / data-theme toggle onlyJavaScript sets an attribute on <html>YesYes, 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 winYesOnly if the inline script is missingAlmost 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.

How the browser resolves which theme to paint A decision flow: a saved user choice in localStorage wins; otherwise the operating-system prefers-color-scheme setting decides; the result sets the data-theme attribute that CSS variables read. How the browser decides which theme to paint 1. localStorage Saved user choice? yes → use it no → fall through 2. OS setting prefers-color-scheme light or dark 3. data-theme set on <html> CSS vars resolve Result flows to the painted page — one attribute, one set of variables

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;
  }
}
Advertisement

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.

SymptomRoot causeFix
White flash for a split second on loadTheme JS runs after CSS paintsMove a blocking theme script into <head> before any stylesheet
Toggle button does nothing on some pagesAttribute 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 modePure white text on dark grey causes halation; contrast too lowUse off-white (#e0e0e0) text and verify 4.5:1 contrast per theme
Logo disappears on dark backgroundWhite logo on transparent PNG blends into dark bgSwap via <picture> + prefers-color-scheme, or filter: invert(1) on monochrome marks
Colors right on refresh, wrong after toggleToggle updates the attribute but not localStoragePersist the choice: localStorage.setItem('color-scheme', newMode) on every toggle
Some components stay lightHard-coded hex values instead of variablesReplace literal colors with var(--…) semantic tokens everywhere
Jarring switch on some elementsNo transition, or transitions on too many propertiesAdd 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.

Semantic variable indirection across light and dark themes A component references the semantic token --bg-primary. In the light theme that token resolves to white; in the dark theme the same token resolves to dark grey. The component code never changes. One token, two themes — the component never changes .card { background: var(--bg-primary) } Light theme :root { --bg-primary: #ffffff } resolves to white Dark theme [data-theme=dark]{--bg-primary:#1a1a1a} resolves to dark grey

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.

Frequently Asked Questions

What is the best way to implement dark mode in CSS?

Define your colors as CSS custom properties (variables) with semantic names like --bg-primary and --text-primary, set light-mode values on :root, and override them inside a [data-theme="dark"] selector. Drive the data-theme attribute with a small script that reads localStorage first and falls back to the prefers-color-scheme media query. This gives you both automatic system detection and a manual toggle from one source of truth.

Should I use prefers-color-scheme or a class-based toggle?

Use both. prefers-color-scheme respects the operating-system setting automatically, but it cannot be overridden by a button because a media query is read-only from JavaScript. A class or data-attribute on the root element lets users pick a theme that differs from their OS. The robust pattern is: default to system preference, then let a saved data-theme attribute win when the user has explicitly chosen.

How do I stop the flash of the wrong theme on page load?

The white flash (FOUC) happens because your theme script runs after the browser has already painted the default light styles. Fix it by placing a tiny blocking inline script in the <head>, before any CSS, that reads localStorage and sets the data-theme attribute on <html> synchronously. Because it runs before first paint, the correct colors are applied immediately with no flicker.

Can I just invert my light-mode colors for dark mode?

No. Inverting colors produces harsh pure-black backgrounds, over-bright text, and washed-out brand colors. Good dark themes use a dark grey background (around #121212 to #1a1a1a) rather than pure black, slightly desaturated and lightened accent colors, and off-white text (around #e0e0e0) instead of pure white to reduce halation. Each theme needs colors chosen deliberately, not flipped.

Do dark-mode colors still need to meet WCAG contrast ratios?

Yes. WCAG 2.1 requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text in every theme you ship. Dark mode is a common place to fail because low-contrast grey-on-grey looks stylish but is unreadable. Check both themes independently with a contrast checker before shipping.

Why does dark mode save battery on some phones but not others?

Dark mode only saves meaningful power on OLED and AMOLED screens, where each pixel emits its own light and black pixels are effectively switched off. On traditional LCD screens the backlight stays fully on regardless of pixel color, so dark mode gives little or no battery benefit. Most modern flagship phones use OLED; many budget phones and laptops still use LCD.

How do I handle images and logos in dark mode?

Use the <picture> element with a media="(prefers-color-scheme: dark)" source to swap in a dark-optimized asset, or apply a CSS filter such as invert(1) to monochrome logos. Photographs usually look fine but can be softened with a slight brightness reduction. Never let a white logo sit on a white transparent PNG that vanishes on a dark background.

Should dark mode transitions be animated?

A short transition (around 0.2 to 0.3 seconds) on background-color and color softens the switch, but keep it subtle and respect the prefers-reduced-motion media query for users who are sensitive to motion. Avoid animating large numbers of elements at once, which can cause a visible repaint lag during the toggle.

dark modecolor managementCSStheme