A light and dark theme with CSS variables: one set of semantic tokens
Name your colour variables by job (background, surface, text, muted, border, action), use those names everywhere, and give them different values for light and dark. Then a light and dark theme with CSS variables is a matter of swapping one small block of values, triggered by prefers-color-scheme, by light-dark(), or by a class a toggle sets.
Two layers of tokens
The mistake that makes dark mode painful is naming variables after colours: --white, --grey-100, --blue. In a dark theme --white has to become dark, and the names start lying. The fix is two layers. Primitive tokens hold the palette and are only ever referenced by the second layer, semantic tokens, which describe a job. Components only use semantic tokens.
| Semantic token | Job | Light value | Dark value |
|---|---|---|---|
--bg | Page background | --stone-50 | --stone-950 |
--surface | Cards, panels, inputs | --white | --stone-900 |
--text | Body and headings | --stone-950 | --stone-100 |
--muted | Secondary text | --stone-600 | --stone-400 |
--border | Lines and outlines | --stone-200 | --stone-800 |
--action | Buttons and links | --teal-700 | --teal-300 |
--on-action | Text on the action colour | --white | --stone-950 |
Seven or eight semantic colours cover a small site. Every one needs checking for contrast in both themes: a teal that passes on white often fails on near-black, which is why the action colour usually needs a lighter value in the dark theme. See semantic colour tokens and design tokens explained for the naming in more depth.
Following the visitor's setting
Custom properties inherit, as MDN's guide to using CSS custom properties explains, so values set on :root reach every element. The classic pattern sets the light values there and overrides them inside a prefers-color-scheme media query:
:root { --bg: #f7f6f2; --text: #17160f; color-scheme: light dark; }@media (prefers-color-scheme: dark) { :root { --bg: #12110d; --text: #f1ede2; } }body { background: var(--bg); color: var(--text); }
The newer light-dark() function, widely supported since 2024, puts both values in one declaration: --bg: light-dark(#f7f6f2, #12110d). It only switches when color-scheme includes dark, so that line isn't optional. The color-scheme property also tells the browser to draw its own parts (scrollbars, form controls, the canvas behind the page) in the matching scheme, which stops a white scrollbar appearing on a dark page. MDN suggests adding the matching meta name="color-scheme" tag too, which reduces a flash of the wrong colours on load.
Adding a theme switcher
Following the system setting needs no script at all. A theme switcher that lets the visitor override it does: a button that sets data-theme="dark" or "light" on the html element, with matching selectors in CSS, and a stored choice so the next page loads in the same theme. Three states work better than two: Light, Dark and Automatic, so someone can return to following their system. Read the stored choice before the page paints, in a small inline script in the head, or the page flashes the default theme first.
- Theme both directions in CSS:
[data-theme="dark"]and[data-theme="light"]should each override the media query, so a manual choice always wins. - Swap images that don't work on dark, such as a logo in black ink, with
pictureand aprefers-color-schemesource, or invert simple icons withcurrentColor. - Reduce shadows in dark mode. They barely show on a dark ground; use a lighter surface for elevation instead.
- Don't use pure black and pure white for large areas. Slightly warm or cool off-blacks are easier on the eyes.
For palette choices, see dark mode colour palette and dark mode website design.
Themes in Anticode
Anticode's design systems are built on semantic colour tokens much like the table above: ground, surface, text, muted text, lines, the action colour and the colour of text on it, each with a light and a dark value. Every system has both. In the visual editor you choose the theme: Auto uses the design system's own palette (Technical and Luxury default to dark, the others to light), Light and Dark pick one explicitly. The page's color-scheme is set to match, so browser controls follow.
One correction to a common assumption: Auto doesn't follow the visitor's light or dark setting. Each published page has one theme, chosen by you. Because design-engine pages run no scripts, there's no theme switcher on them either. In the code editor (Pro mode) you can tune the :root colour tokens, and those changes map back to the project.
Questions people ask
Do I need JavaScript for dark mode?
Not to follow the system setting; a media query or light-dark() does that. You need a little script only for a toggle that remembers a visitor's override.
What does color-scheme do?
It tells the browser which schemes your page supports so it draws scrollbars, form controls and the page canvas to match. It doesn't recolour your own content.
Why does my dark mode flash light on load?
The stored choice is read after the page has painted. Read it in a tiny inline script in the head, and set color-scheme early.
Sources
- Using CSS custom properties (variables), MDN Web Docs, checked
- prefers-color-scheme, MDN Web Docs, checked
- light-dark(), MDN Web Docs, checked
- color-scheme, MDN Web Docs, checked