Dark Mode and Theming
Supporting light and dark themes: the prefers-color-scheme media query, the color-scheme property and light-dark(), theme tokens with custom properties, a theme switch, and contrast in both modes.
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. lightordark: 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.
<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:
-
System (the default): follow
prefers-color-scheme. - Light: always light, whatever the system says.
- 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,ButtonTextandHighlight; - 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.