What dark mode is, and why support it

Dark mode is a color theme with light text on a dark background, the reverse of the traditional dark-on-light page. Operating systems have offered it as a system setting for years (often with an automatic switch at sunset), and many people expect websites to follow along. A bright white page that suddenly fills a dark screen at night is unpleasant at best.

Reasons people choose dark mode:

  • Comfort in dim surroundings, and less glare at night.
  • Some people with light sensitivity, migraine or certain eye conditions find it easier to read.
  • On OLED screens, dark pixels use less power (see displays), which can save battery on phones.
  • Simply personal taste.

Dark mode is not automatically "more accessible": some readers, for example many people with astigmatism, read long text more easily in a light theme. That is the real argument for supporting both: let each reader pick what works for them. The rest of this page shows how to do that with CSS, from the simplest one-line version to a complete theme switch.

Following the system: prefers-color-scheme

The prefers-color-scheme media feature reports the reader's system or browser theme to CSS. Embedded documents are the exception: a page inside an iframe gets its prefers-color-scheme from the color scheme of the frame element, so it matches the page around it. Its values are light and dark. Rules inside @media (prefers-color-scheme: dark) apply only when the reader prefers dark, in the same way as any other media query:

<article class="notice">
  <h2>Planned maintenance</h2>
  <p>The customer portal is offline on Sunday from 02:00 to 04:00.</p>
</article>
body {
  background: #ffffff;
  color: #1f2937;
}

.notice {
  border-left: 4px solid #b45309;
  padding: 0.25rem 1rem;
}

@media (prefers-color-scheme: dark) {
  body {
    background: #111827;
    color: #e5e7eb;
  }

  .notice {
    border-left-color: #fbbf24;
  }
}

The result frame follows this site's theme. Inside a frame, prefers-color-scheme reports the color scheme of the page around it, not your system setting, so when this site is dark (because your system is dark, or because you chose dark with the theme switch at the top) the frame shows light text on a near-black background, and otherwise the light version. Safari is the exception: it still passes on your system setting. To see the other version without changing your system, use the developer tools: in Chrome and Edge the Rendering panel can emulate prefers-color-scheme, and Firefox has a similar toggle in its Inspector (see debugging with developer tools).

This works, but notice what it does not do: if the notice contained a text field or a button, they would still be drawn in the browser's light style, a white box on a dark page. That is the job of the next property.

Telling the browser: the color-scheme property

Even an unstyled page has colors: the browser's default white canvas, black text, blue links, and the look of form controls and scrollbars. The color-scheme property tells the browser which schemes your page supports, so it can switch those built-in colors itself:

<h2>Submit a meter reading</h2>
<p>
  <label for="reading">Reading (kWh)</label>
  <input id="reading" type="number" value="48213">
</p>
<p>
  <label><input type="checkbox" checked> Email me a receipt</label>
</p>
<p><button type="button">Send</button> <a href="#">Help</a></p>
:root {
  color-scheme: light dark;
}

One declaration, no colors of your own: with this site in dark mode (in Safari: with your system in dark mode), the frame shows a dark canvas, light text, a dark input field, a dark checkbox and button, and lighter link colors. The values:

  • normal (the default): the page has not said, and the browser uses its light defaults.
  • light or dark: only that scheme.
  • light dark: both are supported, and the browser picks the one that matches the reader's preference (the first one is preferred when there is no preference).

The same information can go in an HTML <meta> element. The browser then knows it before any CSS has loaded, which prevents a white flash of the default canvas while a dark page loads:

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

Both have been Baseline widely available since August 2024. Use the property even when you set all your colors yourself: it is the only simple way to get form controls and scrollbars that match a dark design.

Theme tokens with custom properties

Repeating every colored rule inside a media query, as in the first example, gets out of hand quickly on a real site. The tidy way is to put every color in a custom property with a name that describes its role (--color-text, --color-surface), not its value (--dark-blue). Components only use those variables. A theme is then nothing more than a different set of values for the same names.

Three layers. On the left, component rules: .card and .button use var(--color-surface), var(--color-text) and var(--color-accent). In the middle, the theme tokens with those names. On the right, two sets of values: the light theme gives --color-surface white, the dark theme a dark gray. Only the values on the right change between themes. components .card .button .notice uses var(…) theme tokens --color-surface --color-text --color-accent light theme #ffffff … dark theme #1f2937 …
Components never name a color directly. Switching theme swaps the values behind the tokens; no component rule changes.
<article class="card">
  <h2>Your energy use</h2>
  <p>September: 212 kWh, 8% less than last year.</p>
  <a class="button" href="#">See details</a>
</article>
:root {
  color-scheme: light dark;
  --color-page: #f3f4f6;
  --color-surface: #ffffff;
  --color-text: #1f2937;
  --color-border: #9ca3af;
  --color-accent: #1d4ed8;
  --color-on-accent: #ffffff;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-page: #030712;
    --color-surface: #1f2937;
    --color-text: #e5e7eb;
    --color-border: #6b7280;
    --color-accent: #93c5fd;
    --color-on-accent: #030712;
  }
}

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

.card {
  max-width: 22rem;
  padding: 0.5rem 1.25rem 1.25rem;
  background: var(--color-surface);
  border: 1px solid var(--color-border);
}

.button {
  display: inline-block;
  padding: 0.5rem 1rem;
  background: var(--color-accent);
  color: var(--color-on-accent);
  text-decoration: none;
}

The component rules (.card, .button) appear once and never mention a color directly. Note the dark accent: not the same blue, but a lighter one, with dark text on top. A color that works as a button background on white is usually too dark on a dark page, so each theme gets its own tuned values.

Such named values are called design tokens. This site works the same way: every color is a custom property in one tokens file, with a second set of values for the dark theme. A redesign or a new theme only touches that file.

Two values in one place: light-dark()

The light-dark() function takes two colors and returns the first in a light color scheme and the second in a dark one. It keeps both values of a token on one line, without a separate media query:

<p class="alert">
  <strong>Outage:</strong> Utrecht-Oost, since 09:40.
  <a href="#">Outage map</a>
</p>
:root {
  color-scheme: light dark;
  --color-text: light-dark(#1f2937, #e5e7eb);
  --color-page: light-dark(#ffffff, #111827);
  --color-alert-bg: light-dark(#fef2f2, #450a0a);
  --color-alert-line: light-dark(#b91c1c, #f87171);
  --color-link: light-dark(#1d4ed8, #93c5fd);
}

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

.alert {
  padding: 1rem;
  background: var(--color-alert-bg);
  border-left: 4px solid var(--color-alert-line);
}

.alert a {
  color: var(--color-link);
}

Two details matter. First, light-dark() does not look at prefers-color-scheme directly: it follows the element's used color scheme, which comes from the color-scheme property. Without color-scheme: light dark (or dark) it always returns the first value. Second, that is also its strength: set color-scheme: dark on one section and everything inside it takes its dark values, which the theme switch below uses.

A theme switch

Following the system is a good default, but some readers want this one site light while their system is dark (or the other way around). A theme switch therefore has three states, not two:

  1. System (the default): follow prefers-color-scheme.
  2. Light: always light, whatever the system says.
  3. Dark: always dark.

With light-dark() tokens, a switch only has to change the color-scheme property; every color follows. In this demo, radio buttons and the :has() selector do that without any script:

<fieldset>
  <legend>Theme</legend>
  <label><input type="radio" name="theme" value="system" checked> System</label>
  <label><input type="radio" name="theme" value="light"> Light</label>
  <label><input type="radio" name="theme" value="dark"> Dark</label>
</fieldset>
<p>Your next bill is due on 28 October. <a href="#">View bill</a></p>
:root {
  color-scheme: light dark;
  --color-page: light-dark(#ffffff, #111827);
  --color-text: light-dark(#1f2937, #e5e7eb);
  --color-link: light-dark(#1d4ed8, #93c5fd);
}

:root:has(input[value="light"]:checked) {
  color-scheme: light;
}

:root:has(input[value="dark"]:checked) {
  color-scheme: dark;
}

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

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

fieldset label {
  margin-right: 1rem;
}

Pick Light or Dark and the whole frame changes, including the radio buttons themselves (they follow color-scheme). The choice is lost when you reload, though, because CSS cannot remember anything. On a real site a few lines of JavaScript store the choice and put it on the <html> element as an attribute, and the CSS reacts to that attribute:

:root                     { color-scheme: light dark; }
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"]  { color-scheme: dark; }
// In a small script in <head>, so it runs before the page is painted
try {
  const saved = localStorage.getItem("theme"); // "light", "dark" or null
  if (saved === "light" || saved === "dark") {
    document.documentElement.dataset.theme = saved;
  }
} catch {
  // Storage blocked (privacy settings): just follow the system.
}

The switch's click handler sets the same attribute and saves the value with localStorage.setItem() (or removes both for "System"). See the DOM and events for handlers and cookies, sessions and state for web storage.

Make the switch itself accessible: a real <button> or a group of radio buttons with a visible label, reachable with the keyboard, and a name that says what it does ("Theme: dark"), not just a sun or moon icon (see accessibility basics).

If you cannot use light-dark() yet, the same three states work with token values in selectors. The system preference then needs a media query that excludes an explicit light choice; this is how this site's own stylesheet does it:

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --color-text: #e5e7eb;
    /* … the other dark values … */
  }
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --color-text: #e5e7eb;
  /* … the same dark values again … */
}

Contrast, images and icons in both modes

A dark theme is a second design, not an inverted first one. Every check you do for the light theme must be done again for the dark one, with the same WCAG requirements: a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text, and 3:1 for the borders of form fields, focus indicators and icons that carry meaning.

  • Avoid the extremes. Pure white text on pure black has the highest contrast possible, but many designers use a very dark gray background and an off-white text, because some readers see bright text on black "glow" or smear. Dark grays also leave room to show depth: a raised card is a slightly lighter gray.
  • Lighten and soften accent colors. Saturated colors that work on white often fail contrast on dark, or seem to vibrate. Use lighter, less saturated versions, as in the examples above.
  • Shadows disappear. A shadow on a dark background is barely visible; use a border or a lighter surface instead.
  • Check focus outlines. A dark-blue focus ring that is clear on white can vanish on a dark page. Give the focus color its own token.

Icons drawn as inline SVG can follow the text color automatically with currentColor, so one icon works in both themes:

<p class="tip">
  <svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
    <circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"></circle>
    <path d="M12 7v6M12 16v1" stroke="currentColor" stroke-width="2"></path>
  </svg>
  Readings are processed within two working days.
</p>
:root {
  color-scheme: light dark;
}

.tip {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  color: light-dark(#92400e, #fcd34d);
}

Photos usually need no change, but images with a white background, such as diagrams, logos and maps, can glare on a dark page. Offer a dark version with <picture> (see links, images and media):

<picture>
  <source srcset="network-map-dark.png" media="(prefers-color-scheme: dark)">
  <img src="network-map-light.png" alt="Map of the Kestrel Energy grid regions" width="800" height="500">
</picture>

Be aware that the media attribute only knows the system setting, not your site's theme switch. If a reader has chosen "dark" on your site while their system is light, they get the light image.

Forced colors mode

Dark mode is a design you offer. Forced colors mode is the reverse: the user picks a small palette, and the browser applies it to every page, replacing the page's own colors. Its best-known form is the contrast themes in Windows (formerly called High Contrast mode), used by people with low vision and by people who need very specific colors to read comfortably; some browsers offer a similar setting of their own.

In forced colors mode the browser, among other things:

  • replaces text, background and border colors with the user's palette, expressed as system colors such as Canvas (background), CanvasText (text), LinkText, ButtonFace, ButtonText and Highlight;
  • removes box shadows;
  • keeps borders and outlines, but in the palette's colors.

The most common breakage follows from that: a button or focus indicator that is visible only through its background color or a shadow becomes invisible. The fix is a transparent border or outline: invisible in normal mode, but turned into a visible line in forced colors mode.

.button {
  background: var(--color-accent);
  border: 2px solid transparent; /* visible only in forced colors mode */
}

.button:focus-visible {
  outline: 3px solid var(--color-focus);
  outline-offset: 2px;
}

@media (forced-colors: active) {
  .status-dot {
    /* a colored dot without text would disappear: give it a system color */
    background: CanvasText;
  }
}

The @media (forced-colors: active) query (Baseline widely available since March 2025) lets you make such small repairs. Resist the temptation to switch the mode off with forced-color-adjust: none on large parts of the page: the reader chose those colors for a reason. Use it only for things whose colors carry the meaning, such as a color picker or a map legend. A related media feature, prefers-contrast, reports whether the user asked for more (or less) contrast in general; macOS and iOS "Increase contrast" sets it, without forcing any colors.