Most dark modes are built the same way: finish the light theme, then go through the code adding dark:bg-gray-900 next to each bg-white. It works for a week. Then someone adds a component without the dark: classes, and the site has a white box in it at night.
This site has two themes and no colour classes. Each colour is a token with a role, each token has a light value and a dark value, and a component only ever names the role. Below is the whole setup in Tailwind v4, with the real values, and the rules that keep it from drifting.
The tokens live in CSS, not in a config
Tailwind v4 moved configuration into CSS, which is the right place for tokens. The light values sit on :root, the dark ones on .dark:
:root {
--background: oklch(0.988 0 0);
--foreground: oklch(0.145 0 0);
--heading: var(--foreground);
--card: oklch(0.988 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.25 0 0);
--border: oklch(0.922 0 0);
}
.dark {
--background: oklch(0.145 0.006 65);
--foreground: oklch(0.87 0.006 65);
--heading: oklch(1 0 0);
--card: oklch(0.145 0.006 65);
--muted: oklch(0.255 0.007 65);
--muted-foreground: oklch(0.82 0.008 65);
--border: oklch(0.31 0.006 65);
}
Then one block maps the variables to utilities:
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-heading: var(--heading);
--color-card: var(--card);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
}
The inline keyword matters. Without it Tailwind bakes the variable's value into the utility at build time, and the dark override on .dark never reaches it. With it, bg-background compiles to background-color: var(--background), and the cascade does the switching.
From here a component writes bg-card text-foreground border-border, and both themes come along. There is no dark: in the component, because the token already knows.
The dark values are not the light ones inverted
Inverting is where most dark modes go wrong. It gives you pure black, pure white, and borders that disappear. Look at the numbers above.
The ground is not black. oklch(0.145 0.006 65): lightness 0.145, a small amount of chroma, hue 65. That hue is warm, a little towards amber. Pure black on an OLED screen makes each edge glow; a warm near-black looks like paper in a dim room.
The text is not white. 0.87 rather than 1. White text on a dark ground glares, and long reads tire the eye. Headings get their own token, --heading, which is pure white in the dark theme and the same ink as the text in the light one. That one step is the hierarchy at night.
The muted level is lighter. In the light theme, muted text is darker than the ground; in the dark theme it is lighter. "Muted" is a role, and the role means "less contrast than the main text", which in each theme is a different direction.
Borders sit one step above the ground. 0.31 on a 0.145 ground. In the light theme the border is 0.922 on a 0.988 ground: close to the ground, barely visible. Same role, same relationship, different numbers.
Accents get brighter. The site's accent ramp (four hues used for sequential things like a workflow diagram) runs from 0.64 to 0.7 lightness in the light theme and from 0.7 to 0.78 in the dark one, with the chroma trimmed a little. A saturated colour that reads on white looks dull on near-black at the same values.
Status colours are measured again. The green that passes AA on the light ground at 0.47 has to be 0.72 on the dark one. Nothing is inverted. Each value is re-measured.
Why oklch
Because the lightness number means what it says. In oklch, 0.87 reads as the same brightness whether the hue is 65 or 290, so a ramp built from "one step above the ground" stays one step above the ground across hues. In hex or HSL two colours with the same lightness value can look two steps apart. Each value on this site is oklch, and I tuned the dark theme by reading the numbers rather than by eyeballing swatches.
The five rules
Tokens are the mechanism. These keep it honest.
- Never hardcode a functional colour. No hex, no
gray-500, nowhite. If a colour is not a token with a role, it does not exist. The two exceptions are named: brand marks that stay the same in both themes (a flag, a logo) and demo content that shows a specific colour on purpose. - Each new token gets both values in the same commit. A token with only a light value is a white box waiting to happen.
dark:only where a token cannot express the difference. This site uses it in one place, the Header's hover fill, where the fill is an alpha of the foreground and the two themes want different alphas. Anywhere else,dark:on a colour is a bug.- Pictures get an outline. A photo on a dark ground needs an edge, and a photo on a light ground needs a different one. One utility,
img-outline, draws a 10% black line inside the image's edge in the light theme and a 10% white one in the dark theme. Noborder-borderon a picture. - Text over a picture ignores the theme. A caption on a photo sits on the photo rather than on the page, so its colour and its scrim are the same in both themes. Those are tokens too, ones with equal values.
The switch
The theme is a class on <html>, set by next-themes, with the variant declared once:
@custom-variant dark (&:where(.dark, .dark *));
Class-based rather than media-query-based, so the reader can choose, and the choice is remembered. color-scheme is set alongside the class, so native controls (scrollbars, form inputs, the date picker) switch with the page.
The switch itself is a crossfade. The whole page, as one snapshot, fades from the old theme to the new one through a view transition over 320ms, so nothing changes colour on its own schedule. Before that it was a circle opening from the button, which looked clever and broke in enough browsers that it went.
The checklist
Before a change ships, in the dark theme:
- No colour class anywhere in the diff. Only roles.
- Each new token has a
.darkvalue. - Borders visible, text not glaring, headings one step above the text.
- Each picture has its outline.
- Accents and status colours measured for contrast on the dark ground.
- The switch looked at both ways, with reduced motion on and off.
Done like this, dark mode is a second column in a table of ten values. The components do not know which column they are in.
If your dark mode is a list of dark: classes, how many components have you shipped since the last time you checked each one of them at night?
