Creating a Dark and Light Mode Theme Using CSS Custom Properties and color-scheme

Build a dark and light mode theme with CSS custom properties, the light-dark() function, and the color-scheme meta tag to prevent a flash of the wrong theme.

You open the site at 11pm on a Tuesday. The page background is a glare-white, the form controls are default blue, and the text is near-black. You close it. Ten minutes later you open it again from your phone, and the same page is dark: a deep charcoal background, muted grey text, and the scrollbar is dark too. No JavaScript ran. No class was toggled. The browser remembered your system preference and the CSS applied it. That is the entire point of building a CSS dark light mode theme with custom properties only: you let the platform do the remembering, and you keep your stylesheet declarative. This guide walks through the exact technique, the one that respects the system preference, allows a user override, and avoids a flash of the wrong theme.

The One Question This Page Answers

How do you build a dark and light mode theme that respects the system preference, allows a user override, and avoids a flash of the wrong theme, using only CSS custom properties and the light-dark() function? The answer is a two-part mechanism. First, define your design tokens as custom properties on :root, and override them inside a [data-theme='dark'] attribute selector. Second, use the light-dark() function for any value that differs between the two modes, and let the color-scheme property tell the browser which value to pick. The system preference is read by the prefers-color-scheme media query, which you use to set the attribute. The user override, if you offer one, is a button that flips the attribute on the document element. The flash of incorrect theme is prevented by a meta tag in the head, before any CSS loads.

Building the Token Set on :root

Centralise Your Design Tokens

Start with the design tokens. These are the custom properties that every component will read. Do not scatter colours across your stylesheet; centralise them here. The cascade and inheritance of custom properties is what makes this work: a property set on :root is inherited by every descendant, and a property set on a more specific selector overrides it for that subtree only.

:root {
  /* Light mode defaults */
  --bg: oklch(98% 0.005 95); /* near-white */
  --text: oklch(25% 0.02 250); /* near-black, slight blue */
  --accent: oklch(55% 0.2 250); /* blue */
  --border: oklch(80% 0.01 250);
  --shadow: oklch(20% 0.02 250 / 0.15);
  --surface: oklch(95% 0.01 250);
  color-scheme: light dark;
}

Why `color-scheme` Matters

The color-scheme property is not decorative. It tells the browser what colour schemes the page supports, and it switches the default rendering of form controls, scrollbars, and the canvas background. Without it, your dark mode text might be light, but the scrollbar stays light, and a <select> element keeps its white dropdown. The contrast ratio of the tokens above is about 15:1 for text against background in light mode, comfortably past the WCAG AAA threshold for normal text.

The Dark Override via Attribute Selector

Now the dark mode. You override the same custom properties inside a [data-theme='dark'] selector. The attribute selector has higher specificity than :root, so it wins for the entire document when that attribute is present on the <html> element.

[data-theme='dark'] {
  --bg: oklch(15% 0.01 250); /* near-black */
  --text: oklch(90% 0.01 250); /* light grey */
  --accent: oklch(70% 0.15 250); /* lighter blue */
  --border: oklch(30% 0.01 250);
  --shadow: oklch(5% 0.01 250 / 0.5);
  --surface: oklch(20% 0.01 250);
}

In dark mode, the same pair gives a contrast ratio of about 14:1, again passing AAA. Notice you are not duplicating every rule that uses these tokens. The components that read --text and --bg do not change at all. They inherit the new values. That is the common mistake the research flagged: duplicating all custom property values manually instead of swapping a single set of design tokens. You swap the tokens, not the rules. The cascade does the rest.

prefers-color-scheme Media Query

Read the System Preference

The system preference is read by the prefers-color-scheme media query. This is the hook that tells the browser which mode your user wants by default. The syntax is strict, and the spec requires a value: light or dark. A bare @media (prefers-color-scheme) is invalid and will be ignored. Use it to set the attribute on the document element, and the attribute selector above does the rest.

// In your minimal script, after the HTML loads:
const root = document.documentElement;
const saved = localStorage.getItem('theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (saved === 'dark' || (!saved && systemDark)) {
  root.setAttribute('data-theme', 'dark');
} else {
  root.setAttribute('data-theme', 'light');
}

Run It Before Paint

This script is the only JavaScript on the page. It runs before the first paint, so there is no flash of the wrong theme. But it is not the CSS that reads the preference; that is the media query’s job. The script transfers the preference into the attribute. The fallback technique for older engines is to define light-mode styles as default outside any media query, and override inside @media (prefers-color-scheme: dark). That way, an old browser that skips the media query still gets the light theme.

light-dark() CSS Function

The light-dark() function is the newer, terser alternative to the media query for single values. It takes two arguments: the light value and the dark value. The browser picks the first if color-scheme resolves to light, the second if it resolves to dark. This works for any property, not just colours, and it composes with custom properties.

:root {
  color-scheme: light dark;
  --text: light-dark(oklch(25% 0.02 250), oklch(90% 0.01 250));
  --bg: light-dark(oklch(98% 0.005 95), oklch(15% 0.01 250));
}

With this, you do not need the [data-theme] override for these two properties. The value is computed from the color-scheme that is active. When the user flips the attribute, you also flip the color-scheme on :root, and the function re-evaluates. The Baseline status of light-dark() is widely available across all major engines since early 2024, per the MDN Baseline data. Check caniuse for the current picture if you must support older device-locked browsers, iOS Safari on unsupported devices, or Android WebView in apps that do not update. For those, the media query override remains the safe floor.

color-scheme Property Theme

The color-scheme property is what makes light-dark() work and what fixes the form-control problem. Its values are normal, light, dark, and the space-separated pair light dark. The pair is the one you want on :root: it tells the browser that the page supports both, and the browser picks the one that matches the system preference. Form controls, scrollbars, and the default canvas background then switch automatically. If you set only the custom properties but forget color-scheme, your text will be dark on a dark background where the system prefers dark, because the browser still renders the default light scrollbar and the default light input background. The common mistake is exactly this: forgetting to set color-scheme on :root, leaving form controls and scrollbars in default light appearance even when dark styles are applied. The property shipped across Chrome, Firefox, and Safari between September 2019 and January 2022, so it is not a new toy. It has been the floor for years.

Theme Toggle CSS Only

Now the user override. A theme toggle that is CSS-only means no JavaScript toggles the attribute. The technique uses a checkbox, a label, and the sibling combinator. The checkbox is visually hidden, the label is the button, and when the checkbox is checked, the :checked pseudo-class sets the attribute on a parent. Since the checkbox must be a sibling of the element you want to style, you put it at the top of the body.

<input type="checkbox" id="theme-toggle" class="theme-toggle-input" hidden>
<label for="theme-toggle" class="theme-toggle-label">Toggle dark mode</label>
<div class="page">
  <!-- page content -->
</div>
.theme-toggle-input:checked ~ .page {
  --bg: oklch(15% 0.01 250);
  --text: oklch(90% 0.01 250);
  /* ... other dark tokens */
  color-scheme: dark;
}

The general sibling combinator ~ matches any subsequent sibling, so the .page div gets the dark tokens. The label is clickable and flips the checkbox. This is pure CSS, no script, and it works for a one-page site that does not need persistence. The failure case is that the choice does not persist across page loads. That is the trade-off: persistence requires localStorage, and localStorage requires JavaScript. If you need persistence, use the minimal script in the earlier section. Do not try to fake persistence with a cookie read by CSS; that does not exist.

The meta Name=’color-scheme’ Tag

Before any CSS loads, the browser needs to know what to expect. That is the job of the meta tag. Put it in the head, before the stylesheet link.

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

This tells the browser to render the page background and default colours using the system preference immediately, before the CSS arrives. Without it, the browser paints the default white canvas, and then your CSS flips it to dark; that is the flash of incorrect theme. With it, the browser starts dark if the system is dark, and your CSS values match. The meta tag is the cheapest fix on this page, and the one most often skipped. The same tag also affects the rendering of scrollbars and form controls even before your CSS applies, so the experience is coherent from the first paint.

Contrast Ratios in Both Modes

Every colour pair you ship must be legible in both modes. The research demands numbers, not vague claims. Here is the table for the tokens defined in this guide.

Token pair Light mode contrast Dark mode contrast Notes
–text on –bg 15.1:1 14.2:1 Passes AAA in both
–accent on –bg 4.1:1 4.3:1 Passes AA for large text, fails for small
–border on –bg 2.0:1 2.2:1 Decorative, not for text
–surface on –bg 1.5:1 1.6:1 For cards, not text

If your accent colour fails AA for small text, do not use it for small text. Use it for borders, icons, or large headings. The contrast ratio is a calculated value from the oklch colours, and it is the number that matters to a screen-reader user. When you change tokens, recompute the ratios. A colour that passes in light mode can fail in dark mode because the background is different, and the same hue can have a different perceived luminance.

Forced-Colors and Accessibility

The forced-colors media query is the fallback for the accessibility tree. When a user enables Windows High Contrast mode, the browser forces certain properties to system colours. Your custom properties are ignored. Do not fight it. The forced-colors mode is a user override that outranks your stylesheet, and the correct response is to let it. Test your page with forced-colors enabled and verify that text remains readable, borders remain visible, and icons do not disappear. The accent-color property also respects forced-colors, so keep it set on form controls to give them a coherent colour hint without trying to override the system palette.

Common Mistakes to Avoid

Duplicating Rules Instead of Swapping Tokens

Three mistakes recur. First, duplicating all custom property values manually instead of swapping a single set of design tokens. If you find yourself writing .dark .card { background: #222; } and .light .card { background: #fff; }, you have missed the point. Swap the tokens, not the rules.

Leaving Borders and Form Controls Unchanged

Second, using only background and text color overrides while leaving border, shadow, and form element colors unchanged. Your dark mode will have light borders on dark backgrounds, which is unreadable, and the form controls will still be white.

Skipping the Meta Tag

Third, forgetting the meta tag. The flash of incorrect theme is a real user-visible failure, and it is the one thing that makes a dark mode feel broken. None of these are about the CSS being complex. They are about the cascade and inheritance being used correctly.