Dark mode has gone from a nice-to-have to an expected feature. Most operating systems now ship with a system-wide dark mode preference, and users expect websites to respect it. Here is how to implement it properly using CSS custom properties, the prefers-color-scheme media query, and a manual toggle with localStorage persistence.

Step 1: Define Color Variables

The foundation of dark mode is CSS custom properties (variables). Instead of hardcoding colors throughout your stylesheets, define them once in :root and reference them everywhere:

:root {
  --bg-primary: #ffffff;
  --bg-secondary: #f5f5f5;
  --text-primary: #1a1a1a;
  --text-secondary: #666666;
  --border-color: #e0e0e0;
  --accent-color: #0066cc;
  --code-bg: #f0f0f0;
}

body {
  background-color: var(--bg-primary);
  color: var(--text-primary);
}

.card {
  background: var(--bg-secondary);
  border: 1px solid var(--border-color);
}

a {
  color: var(--accent-color);
}

Every color in your CSS should reference a variable. If you find yourself writing a hex code directly on an element, stop and create a variable for it instead.

Step 2: Detect System Preference

The prefers-color-scheme media query detects the user's operating system theme preference:

@media (prefers-color-scheme: dark) {
  :root {
    --bg-primary: #1a1a1a;
    --bg-secondary: #2d2d2d;
    --text-primary: #e0e0e0;
    --text-secondary: #a0a0a0;
    --border-color: #404040;
    --accent-color: #4d9fff;
    --code-bg: #2d2d2d;
  }
}

With just these two blocks of CSS, your site automatically switches between light and dark mode based on the user's system setting. No JavaScript required. This is the minimum viable dark mode implementation, and for many sites it is sufficient.

Step 3: Add a Manual Toggle

Some users want to override the system preference for specific sites. To support this, add a toggle button and use a CSS class to control the theme:

<button id="theme-toggle" aria-label="Toggle dark mode">
  Toggle Theme
</button>

Restructure the CSS to use a class-based override:

/* Light theme (default) */
:root {
  --bg-primary: #ffffff;
  --text-primary: #1a1a1a;
  /* ... other variables */
}

/* Dark theme via system preference */
@media (prefers-color-scheme: dark) {
  :root:not(.light-mode) {
    --bg-primary: #1a1a1a;
    --text-primary: #e0e0e0;
    /* ... dark overrides */
  }
}

/* Dark theme via manual toggle */
:root.dark-mode {
  --bg-primary: #1a1a1a;
  --text-primary: #e0e0e0;
  /* ... dark overrides */
}

The :not(.light-mode) selector ensures that if a user manually selects light mode, the system-level dark preference does not override it.

Step 4: Persist with localStorage

The toggle means nothing if the preference resets on every page load. Use localStorage to remember the user's choice:

const toggle = document.getElementById('theme-toggle');
const root = document.documentElement;

// Check for saved preference
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
  root.classList.add(savedTheme);
}

toggle.addEventListener('click', () => {
  if (root.classList.contains('dark-mode')) {
    root.classList.remove('dark-mode');
    root.classList.add('light-mode');
    localStorage.setItem('theme', 'light-mode');
  } else {
    root.classList.remove('light-mode');
    root.classList.add('dark-mode');
    localStorage.setItem('theme', 'dark-mode');
  }
});

Avoiding the Flash of Wrong Theme

There is a common problem: when a user has dark mode saved, the page briefly flashes white before the JavaScript runs and applies the dark class. This is called FOUWT (Flash of Unstyled/Wrong Theme).

Fix it by placing a small inline script in the <head>, before any CSS loads:

<script>
  (function() {
    const saved = localStorage.getItem('theme');
    if (saved) {
      document.documentElement.classList.add(saved);
    }
  })();
</script>

This runs synchronously before the browser paints, so the correct theme is applied from the first frame.

Design Considerations

A few things to keep in mind when designing dark mode:

  • Do not just invert colors - Pure white text on pure black is harsh. Use off-white (#e0e0e0) on dark gray (#1a1a1a) instead.
  • Reduce contrast slightly - Dark mode should feel softer. High-contrast dark themes cause eye strain.
  • Adjust shadows - Box shadows that look good on light backgrounds become invisible on dark ones. Replace shadows with subtle borders or lighter background tones.
  • Test images - Images with transparent backgrounds designed for light mode may look wrong on dark backgrounds. Consider adding a subtle background or border.
  • Check accessibility - Ensure your dark mode color combinations still meet WCAG contrast requirements. Use a tool like the WebAIM contrast checker.

Summary

The implementation order matters: start with CSS variables, add the system preference media query, then layer on the manual toggle if needed. Most users will be happy with the automatic system detection alone. The toggle is a nice extra for the users who want fine-grained control.