Dark mode CSS: one media query and a set of variables

If every colour on the page comes from a CSS variable, dark mode is one media query that redefines four of them when the reader's system prefers dark. Text, backgrounds, borders and inline SVG charts all follow. Hard-coded colours are the only thing that does not.

Dark mode CSS is a single media query, @media (prefers-color-scheme: dark), that redefines the page's colour variables when the reader's operating system is set to dark.

The markup. The highlighted line is the part this term is about.
The markup. The highlighted line is the part this term is about.

If the page draws every colour from a variable, background, ink, muted and accent, the whole page follows in one block of four lines, including inline SVG charts whose fills use the same variables.

If colours are hard-coded, each one has to be found and paired. This guide covers the choices that matter, contrast in both schemes, images and diagrams, and a manual toggle for readers who want to override the system.

:root {
  --bg: #ffffff;
  --fg: #18181b;
  --fg-2: #52525b;
  --line: #e7e7ea;
  --accent: #d5f525;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #18181b;
    --fg: #e7e7ea;
    --fg-2: #a1a1aa;
    --line: #333338;
    --accent: #d5f525;
  }
}

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

That is the whole pattern. Everything that uses the variables follows automatically.

Which is the real argument for defining colours as variables in the first place — see putting your colours into generated HTML. Hard-coded hex values mean dark mode is a rewrite rather than a block.

Dark mode CSS: choices that matter

Not pure black

Dark mode CSS is one media query that redefines the page's colour variables when the reader's system prefers dark. If every colour in the page comes from a variable, the whole page, charts included, follows with no second stylesheet.
Dark mode CSS is one media query that redefines the page's colour variables when the reader's system prefers dark. If every colour in the page comes from a variable, the whole page, charts included, follows with no second stylesheet.

#000 with #fff text produces uncomfortable glare and, on OLED screens, visible smearing on scroll. A very dark grey — around #18181b — with slightly-off-white text reads better.

Lower the contrast slightly, not the legibility

Light text on dark appears heavier than dark on light at the same weight. Two adjustments help:

@media (prefers-color-scheme: dark) {
  body { font-weight: 380; }          /* if the font supports it */
  strong { font-weight: 650; }
}

Borders need to be lighter, not darker

In light mode a border is darker than the background. In dark mode it must be lighter — #333338 against #18181b. Reusing the light-mode border value makes every border vanish.

Shadows do not work

A drop shadow is a darkening, and darkening a dark background is invisible. Use a border instead:

.card { border: 1px solid var(--line); }
@media (prefers-color-scheme: dark) {
  .card { box-shadow: none; }
}
A screenshot of the page ✗ Text is not selectable ✗ Numbers cannot be copied ✗ Charts stop being interactive ✗ Goes stale the moment data changes The live page ✓ Text selects and copies ✓ Tables can be read by tools ✓ Charts still respond to hover ✓ Update the source, link is current
A chart as markup follows the page's colours. A chart as a picture stays light on a dark page.

Images and diagrams

A fixed-colour PNG diagram stays light-themed while the page goes dark, which looks like a bug.

Inline SVG solves it, because its colours can come from your variables:

<svg viewBox="0 0 200 40">
  <rect width="140" height="20" fill="var(--accent)"/>
  <line x1="0" y1="34" x2="200" y2="34" stroke="var(--line)"/>
</svg>

Every diagram on this site works this way. For photographs, nothing needs to change. For a screenshot with a white background, a subtle border keeps it from looking like a hole in the page.

Form controls

:root { color-scheme: light dark; }

One line, and browser-drawn UI — form field backgrounds, scrollbars, date pickers — switches to its dark variant. Without it you get a dark page with bright white input fields, which is the most common visible oversight.

The icon too

A favicon can respond as well, since an SVG can carry its own media query:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
  <style>
    .mark { fill: #202600 }
    @media (prefers-color-scheme: dark) { .mark { fill: #d5f525 } }
  </style>
  <path class="mark" d="M13 9 L7 16 L13 23"/>
</svg>

Testing

Switch your operating system's appearance setting, or use the emulation control in the browser's developer tools — most have a way to force prefers-color-scheme.

Four things to check: borders are still visible, no white panels remain, form fields are dark, and any diagram has followed.

Why it is worth the twenty minutes

A large number of readers have dark mode on permanently, particularly on phones. A page that ignores it arrives as a bright rectangle at night, and the usual response is to close it.

Since a shared link is most often opened on a phone, this is not a refinement — it is part of whether the page gets read.

The checklist

Element Light Dark Common mistake
Background #fff #18181b Using pure black
Body text #18181b #e7e7ea Using pure white
Borders Darker than background Lighter than background Reusing the light value, so borders vanish
Cards Shadow Border Keeping the shadow, which is invisible
Form fields Browser default Needs color-scheme White boxes on a dark page
Diagrams Any Must follow A fixed-colour image staying light
Accent Brand colour Often the same Contrast failing against the dark background

The border row is the one that catches everyone, because nothing looks broken — the page simply loses its structure and reads as a wall of text.

Letting the reader choose as well

The media query follows the operating system. If you want a toggle, drive it from an attribute and keep the query as the default:

:root { --bg: #fff; --fg: #18181b; }
@media (prefers-color-scheme: dark) {
  :root { --bg: #18181b; --fg: #e7e7ea; }
}
[data-theme="dark"]  { --bg: #18181b; --fg: #e7e7ea; }
[data-theme="light"] { --bg: #fff;    --fg: #18181b; }

Set data-theme on the <html> element and store the choice — see local storage, and wrap the call, because it throws inside restricted frames. With no attribute set, the reader's system preference still applies, which is the correct default.

A manual toggle

Some readers want dark mode regardless of their system setting, or light mode on a dark system.

A toggle is a class on the root element, html.dark, with the same four variables redefined under it, and a few lines of script that set the class and remember the choice in local storage.

Keep the media query as the default and let the class override it, so a reader who never touches the toggle gets their system preference.

What dark mode is not

It is not an inverted page. Inverting swaps every colour, turns photographs into negatives and makes the accent muddy. Dark mode is a second palette chosen for a dark background: near-black rather than black, off-white rather than white, an accent bright enough to carry, and photographs left alone. Four variables is enough because the palette is small on purpose.

Adding dark mode: 4 steps

  1. Put every colour in a variable. Four is usually enough: background, ink, muted, accent. Replace hard-coded values with var(). Restyling generated HTML is the same exercise.
  2. Redefine them inside the media query. A dark background, light ink, a muted grey that still reads, and an accent that carries on dark.
  3. Check contrast in both schemes. 4.5 to 1 for body text in each. The accent that worked on white often needs to change on dark.
  4. Use the variables in SVG and images too. fill: var(--accent) on inline charts; light and dark versions of a logo swapped by the same query.

Questions people ask

How do I support dark mode?

Define your colours as CSS variables, then override them inside a prefers-color-scheme: dark media query. Everything using the variables follows.

Should dark mode use pure black?

No. A very dark grey around #18181b is easier to read against — pure black with white text produces uncomfortable glare.

What breaks in dark mode?

Fixed-colour images, drop shadows, and any colour written directly into the markup or an SVG rather than referenced from a variable.

Is it worth doing?

Yes for anything read on a phone. Many people have dark mode on permanently, and a page that ignores it arrives as a bright white rectangle at night.

Keep reading