Type something to search...
How to Implement Dark Mode with CSS and prefers-color-scheme

How to Implement Dark Mode with CSS and prefers-color-scheme

Open your phone's settings at night and there's a good chance dark mode switches on automatically. Many of your visitors browse this way, and landing on a blinding white page after sunset is jarring. A well-built dark theme isn't just a nice touch anymore; users expect it.

The good news is that implementing dark mode has become much simpler. The prefers-color-scheme media query tells you what the user's operating system prefers, color-scheme makes built-in browser UI match, and the light-dark() function lets you define both colours in a single declaration. Add a few lines of JavaScript for a manual toggle, and you have a complete, robust solution.

In this guide, I'll walk you through a production-ready dark mode setup from the ground up: design tokens, system preference detection, a toggle that remembers the user's choice, and the details that separate a polished dark theme from an inverted one.

Start with Design Tokens

The single most important decision is to never hard-code colours in components. Instead, define a small set of semantic custom properties and use them everywhere. Dark mode then becomes a matter of changing the values of those tokens, not rewriting components.

:root {
  --color-bg: #ffffff;
  --color-surface: #f8fafc;
  --color-text: #0f172a;
  --color-text-muted: #475569;
  --color-border: #e2e8f0;
  --color-accent: #4f46e5;
  --color-accent-contrast: #ffffff;
}

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

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

.card p {
  color: var(--color-text-muted);
}

.button {
  background: var(--color-accent);
  color: var(--color-accent-contrast);
}

Name tokens by role, not by colour. --color-surface still makes sense in dark mode; --color-light-gray doesn't.

Detecting the System Preference

The prefers-color-scheme media query matches the user's operating system or browser theme setting. It has two useful values, light and dark.

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #0b1120;
    --color-surface: #111827;
    --color-text: #e2e8f0;
    --color-text-muted: #94a3b8;
    --color-border: #1f2937;
    --color-accent: #818cf8;
    --color-accent-contrast: #0b1120;
  }
}

That's enough for a basic, automatic dark mode. When the user switches their system theme, the page updates instantly, with no reload needed.

Tell the Browser with color-scheme

Your custom properties control your own components, but browsers also render plenty of built-in UI: form controls, scrollbars, the default canvas colour, and spell-check underlines. Without guidance, these stay light even when your page is dark, leaving white checkboxes and bright scrollbars on a dark background.

The color-scheme property fixes that:

:root {
  color-scheme: light dark;
}

This declares that your page supports both schemes. The browser then renders native UI in whichever scheme is active. You can also add the equivalent meta tag, which the browser can read before your CSS loads:

<meta name="color-scheme" content="light dark" />

Using both is a good habit. The meta tag helps the very first paint, and the CSS property can be changed later for a manual toggle.

The light-dark() Function

Maintaining two separate blocks of token values works, but it spreads each colour across two places. The light-dark() function lets you define both values side by side:

:root {
  color-scheme: light dark;

  --color-bg: light-dark(#ffffff, #0b1120);
  --color-surface: light-dark(#f8fafc, #111827);
  --color-text: light-dark(#0f172a, #e2e8f0);
  --color-text-muted: light-dark(#475569, #94a3b8);
  --color-border: light-dark(#e2e8f0, #1f2937);
  --color-accent: light-dark(#4f46e5, #818cf8);
  --color-accent-contrast: light-dark(#ffffff, #0b1120);
}

light-dark() returns the first value when the element's used colour scheme is light, and the second when it's dark. It doesn't read the media query directly. It reads the color-scheme of the element, which is why color-scheme: light dark must be set for it to work.

light-dark() is supported in all current major browsers and is part of Baseline. If you need to support older browsers, the media query approach from the previous section is a safe fallback, and you can layer them:

:root {
  --color-bg: #ffffff;
  --color-text: #0f172a;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #0b1120;
    --color-text: #e2e8f0;
  }
}

@supports (color: light-dark(#000, #fff)) {
  :root {
    --color-bg: light-dark(#ffffff, #0b1120);
    --color-text: light-dark(#0f172a, #e2e8f0);
  }
}

One limitation: light-dark() only accepts colours. For images, shadows with non-colour parts, or other values, you still need a media query or a theme attribute selector.

Adding a Manual Toggle

Respecting the system setting is the right default, but many users want to override it for a specific site. A good toggle offers three options: light, dark, and system.

The cleanest approach with light-dark() is to change color-scheme on the root element. Everything that uses light-dark() follows automatically.

:root {
  color-scheme: light dark;
}

:root[data-theme="light"] {
  color-scheme: light;
}

:root[data-theme="dark"] {
  color-scheme: dark;
}

With no data-theme attribute, the page follows the system. With data-theme="dark", it's forced dark, and vice versa.

If you're using the media query approach instead of light-dark(), you need to apply the dark tokens both in the media query (guarded so it doesn't override a forced light theme) and under the attribute:

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-bg: #0b1120;
    --color-text: #e2e8f0;
  }
}

:root[data-theme="dark"] {
  --color-bg: #0b1120;
  --color-text: #e2e8f0;
}

The duplication is the price of broader support. A preprocessor mixin or a build step can generate both blocks from one source.

The Toggle Markup

A simple, accessible control using a select element:

<label class="theme-picker">
  <span>Theme</span>
  <select id="theme-select">
    <option value="system">System</option>
    <option value="light">Light</option>
    <option value="dark">Dark</option>
  </select>
</label>

A group of radio buttons or a button that cycles through options works too. Whatever you choose, give it a visible label or an aria-label so screen reader users know what it does.

The Toggle Script

const select = document.getElementById("theme-select");
const root = document.documentElement;

function applyTheme(theme) {
  if (theme === "light" || theme === "dark") {
    root.dataset.theme = theme;
  } else {
    delete root.dataset.theme;
  }
}

function readStoredTheme() {
  try {
    return localStorage.getItem("theme") || "system";
  } catch {
    return "system";
  }
}

select.value = readStoredTheme();

select.addEventListener("change", () => {
  const theme = select.value;
  applyTheme(theme);
  try {
    if (theme === "system") {
      localStorage.removeItem("theme");
    } else {
      localStorage.setItem("theme", theme);
    }
  } catch {
    /* storage unavailable, the choice lasts for this page view only */
  }
});

Storage access is wrapped in try...catch because localStorage can throw in some privacy modes. The site should still work without it.

Preventing the Flash of the Wrong Theme

If you run the toggle script at the end of the page, users who picked dark on a light system will see a brief white flash before the script applies their preference. This is often called FOUC or "flash of incorrect theme".

The fix is a tiny inline script in the head, before any stylesheets, that applies the stored theme immediately:

<head>
  <meta name="color-scheme" content="light dark" />
  <script>
    try {
      const theme = localStorage.getItem("theme");
      if (theme === "light" || theme === "dark") {
        document.documentElement.dataset.theme = theme;
      }
    } catch {}
  </script>
  <link rel="stylesheet" href="/styles.css" />
</head>

Because this runs before the first paint and is synchronous, the correct theme is in place when the page first renders. Keep it tiny, since it blocks rendering while it runs. On server-rendered sites, an alternative is to store the preference in a cookie and render the data-theme attribute on the server.

Designing a Good Dark Theme

Swapping white for black and black for white technically produces a dark theme, but rarely a good one. A few principles make a real difference.

Avoid Pure Black and Pure White

Pure #000 backgrounds with pure #fff text create harsh contrast that causes eye strain and a halation effect for some readers. Use a very dark blue-gray or neutral for backgrounds, and a slightly off-white for text.

Express Elevation with Lightness

In light themes, shadows show depth. In dark themes, shadows are nearly invisible against dark backgrounds. Instead, make raised surfaces lighter than the background:

:root {
  --color-bg: light-dark(#ffffff, #0b1120);
  --color-surface-1: light-dark(#f8fafc, #111827);
  --color-surface-2: light-dark(#f1f5f9, #1f2937);
  --shadow-color: light-dark(rgb(15 23 42 / 0.12), rgb(0 0 0 / 0.5));
}

.modal {
  background: var(--color-surface-2);
  box-shadow: 0 20px 40px var(--shadow-color);
}

Desaturate Accent Colours

Highly saturated colours that look great on white can vibrate on dark backgrounds. Lighter, slightly less saturated versions of your brand colours usually read better. In the tokens above, the accent shifts from a deep indigo to a lighter one for exactly this reason.

Check Contrast in Both Themes

Every text and background pairing must meet WCAG contrast requirements in both themes: at least 4.5 to 1 for normal text and 3 to 1 for large text. Browser DevTools show the contrast ratio in the colour picker. Test muted text and disabled states especially, since they're the most likely to fail.

Handle Images and Media

Photos usually look fine in both themes, but some assets need attention:

  • Logos and diagrams with transparent backgrounds and dark lines can disappear on dark backgrounds. Provide an alternate version with picture:
<picture>
  <source srcset="/img/logo-dark.svg" media="(prefers-color-scheme: dark)" />
  <img src="/img/logo-light.svg" alt="TideWave" />
</picture>

Keep in mind that the media attribute follows the system preference, not your manual toggle. If you support a toggle, swapping the image with CSS based on data-theme is more reliable.

  • Bright photos can be toned down slightly with a filter:
:root[data-theme="dark"] img:not([src$=".svg"]) {
  filter: brightness(0.9);
}
  • Inline SVG icons should use currentColor so they inherit the text colour automatically.

Smooth Theme Transitions

Switching themes instantly is fine, but a brief transition can feel more polished. Transition the specific colour properties rather than all:

body,
.card,
.button {
  transition:
    background-color 200ms ease,
    color 200ms ease,
    border-color 200ms ease;
}

@media (prefers-reduced-motion: reduce) {
  body,
  .card,
  .button {
    transition: none;
  }
}

Be aware that transitioning colours across hundreds of elements at once can be expensive. If the switch feels sluggish, remove the transition. Instant switching is a perfectly good experience.

Testing Dark Mode

You don't need to change your operating system setting to test. In Chromium-based browsers, open DevTools, go to the Rendering panel, and use Emulate CSS media feature prefers-color-scheme. Firefox DevTools have toggle buttons in the Inspector's rules panel to simulate light and dark schemes. Test with each combination:

  1. System light, no stored preference.
  2. System dark, no stored preference.
  3. System light, forced dark.
  4. System dark, forced light.

Check form controls, scrollbars, focus rings, code blocks, and third-party embeds in each case.

Common Pitfalls

  • Forgetting color-scheme. Without it, native form controls and scrollbars stay light, and light-dark() always returns the light value.
  • Hard-coded colours in components. A single color: #333 buried in a component will be unreadable on a dark background. Search your codebase for hex values outside your token file.
  • Loading the theme script too late. Any script that applies the stored theme after the first paint causes a flash.
  • Ignoring third-party content. Embedded widgets and iframes may not respect your theme. Check them and choose dark variants where available.

Conclusion

A solid dark mode comes down to a few building blocks. Define semantic colour tokens, set color-scheme: light dark so the browser knows you support both, and use light-dark() or prefers-color-scheme to give each token its dark value. Layer a three-way toggle on top by changing color-scheme or a data-theme attribute, and apply the stored choice with a tiny inline script before first paint.

Then treat the dark theme as a design in its own right: avoid pure black, show elevation with lighter surfaces, soften saturated accents, and check contrast in both modes. Your night-time visitors will notice.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading