Skip to content
9 min read

Build one theme or build three.

Light and dark mode can make paired hard-coded colours look like a theme system. A loud third theme reveals whether components use semantic tokens or just two sets of guesses.

  • frontend
  • web-design
  • design-systems
  • architecture

The third theme is a snitch.

Light mode looked tidy. Dark mode looked tidy. Then I switched the site to a fluorescent development palette—acid yellow, electric cyan, hot pink—and one perfectly white card stayed perfectly white.

So did a grey icon. A chart tooltip kept its black background. A loading skeleton turned into a row of little concrete slabs.

The new theme was not broken. It was exposing all the places where light and dark had only been painted on.

The card had not really been themed. It had been given two sets of instructions:

<section className="border border-zinc-200 bg-white text-zinc-950 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50">
	{/* ... */}
</section>

This is valid Tailwind. It can produce a perfect light screenshot and a perfect dark one.

But the component owns six absolute colour decisions. Add a hover state, muted copy, an inline SVG, or that chart tooltip and it owns several more. The light and dark values come in pairs, but they are still scattered through the component tree.

That component has two costumes. It does not have a theme contract.

This is why I prefer building for either one theme or at least three. One theme can stay honestly simple. Three themes create enough pressure to build the real thing. Two sit in an awkward middle: enough variation to create maintenance work, but not enough to make the missing abstraction hurt yet.

If the product only needs light and dark, that is fine. Ship two themes. Just build them as though a third is waiting.

Two themes are unusually good at hiding the problem#

With one theme, bg-white is honest. The interface is white. Nobody has promised otherwise.

With two, every literal can pick up a partner. Then every state can do the same:

  • bg-white dark:bg-zinc-950
  • hover:bg-zinc-50 dark:hover:bg-zinc-900
  • disabled:text-zinc-400 dark:disabled:text-zinc-600

It feels systematic because every value has a matching opposite. The problem arrives later, when two cards choose slightly different zinc values, one developer forgets the dark hover state, or a disabled button inherits the wrong half of the pair.

A third theme ruins the trick. We can add another variant to every colour on every component, or we can finally admit that all those components are asking the same question: what colour is a surface in this theme?

One value can be styling. Two values can hide in a conditional. Three values demand a vocabulary.

Once the palettes become data, a component no longer asks for white in light mode and zinc in dark mode. It asks for surface.

Ask for a job, not a colour#

A useful token describes a job or a relationship: canvas, surface, foreground, muted-foreground, border, action, and on-action. It does not describe the hue currently doing that job.

Primitive colours can still exist inside the palette. blue-600 tells us what a colour is; action tells us why a component needs it. Components should depend on the second one.

Android's colour guidance makes the distinction explicit: roles describe use, such as on-primary, rather than hue, such as on-blue. The Design Tokens Community Group specification calls the connection between a semantic token and its value an alias or reference. Change the value and the component consuming the role does not have to know.

The whole contract can be a table:

RoleLightDarkDebug
Canvascool whitedeep navyacid yellow
Surfacewhitecharcoalelectric cyan
Foregroundnear blackwarm whitedeep violet
Muted textslateblue-greysmoky violet
Bordercool greyslatehot pink
Actionindigoperiwinkleelectric blue
Action hoverdeep indigopale lavenderroyal blue

Every theme has to fill its column. Components only name the roles in the first column; they never choose a palette value.

Tailwind makes both architectures easy#

The dark: variant is not the villain here. It is useful when something truly behaves differently in dark mode: perhaps an illustration needs less glare or a photograph needs a different overlay. The trouble starts when every component repeats the core palette mapping.

Tailwind v4 supports mapping runtime CSS custom properties into utilities with @theme inline, so the palette can live in one place:

:root {
	--theme-canvas: #f8fafc;
	--theme-surface: #ffffff;
	--theme-foreground: #172033;
	--theme-muted-foreground: #5f6b7a;
	--theme-border: #d8dee9;
	--theme-action: #4f46e5;
	--theme-action-hover: #4338ca;
	--theme-on-action: #ffffff;
	color-scheme: light;
}

[data-theme="dark"] {
	--theme-canvas: #0f172a;
	--theme-surface: #172033;
	--theme-foreground: #f8fafc;
	--theme-muted-foreground: #b6c0ce;
	--theme-border: #334155;
	--theme-action: #a5b4fc;
	--theme-action-hover: #c7d2fe;
	--theme-on-action: #172033;
	color-scheme: dark;
}

[data-theme="debug"] {
	--theme-canvas: #f4ff8c;
	--theme-surface: #ddfbff;
	--theme-foreground: #210046;
	--theme-muted-foreground: #655273;
	--theme-border: #e000a4;
	--theme-action: #0057ff;
	--theme-action-hover: #003ccb;
	--theme-on-action: #ffffff;
	color-scheme: light;
}

@theme inline {
	--color-canvas: var(--theme-canvas);
	--color-surface: var(--theme-surface);
	--color-foreground: var(--theme-foreground);
	--color-muted-foreground: var(--theme-muted-foreground);
	--color-border: var(--theme-border);
	--color-action: var(--theme-action);
	--color-action-hover: var(--theme-action-hover);
	--color-on-action: var(--theme-on-action);
}

The component becomes boring:

<section className="border border-border bg-surface text-foreground">
	<h2>Billing details</h2>
	<p className="text-muted-foreground">Used for future invoices.</p>
	<button className="bg-action text-on-action hover:bg-action-hover">
		Save
	</button>
</section>

Now changing data-theme changes the answers, not the markup.

For a stronger guardrail, Tailwind can also disable its default colour namespace with --color-*: initial before declaring the approved tokens. That removes the temptation to reach for zinc-600 because it happens to look right today. Arbitrary values remain possible, but at least they look like the exception they are.

Make the third theme loud on purpose#

The third theme should not be dark mode with a slightly bluer accent. That is too polite. It needs to change enough of the palette that literal values cannot accidentally look correct.

I call mine Debug, partly so nobody mistakes it for a tasteful new brand direction. Its canvas is acid yellow. Its surfaces are cyan. Its borders are pink and its text is violet. It is loud enough that a default white, grey, or black cannot blend into the scenery.

The useful part is not ugliness for its own sake. Debug changes both hue and brightness across the canvas, surfaces, text, borders, and actions. If its canvas were just another off-white, a forgotten bg-white could still hide. The palette should remain deliberate and accessible; the point is to expose the wrong values, not make the right ones unreadable.

Then walk the product or component library in that theme.

Do not stop at the default state. Hover buttons, focus fields, disable controls, open menus, trigger loading states, and select rows. Those less-photogenic states are exactly where paired literals tend to accumulate.

A forgotten bg-white becomes a white island on yellow. A hard-coded dark SVG clashes with the violet foreground. A translucent overlay mixed from black makes the cyan surface look dirty. Syntax highlighting, shadows, loading skeletons, charts, and third-party widgets that bypassed the contract become impossible to miss.

At that point, each stubborn colour has to be classified. Either it should use a semantic role, or it is intentionally static and needs to say so. The Debug theme turns that architectural question into something we can see from across the room.

Use it like a test#

The third theme never has to reach production. Put it behind a development flag. Add it to Storybook's theme switcher. Render each component in light, dark, and Debug during screenshot tests. Give designers the same mode in Figma so problems do not have to wait for code.

Then use it during ordinary work. A test nobody runs is just a brightly coloured souvenir.

Keep the accessibility work alongside this diagnostic pass. Every theme still needs contrast checks; semantic names do not guarantee accessible values.

A light/dark/system control also produces only two palettes—“system” is a preference that selects one of them. And Debug is not a substitute for the browser's forced-colors mode, where the user agent enforces a user-chosen limited palette.

Absolute colour can still be intentional#

Colour is sometimes the content. A company logo, national flag, team colour, photograph, or labelled data series may need to keep a stable hue. Adobe Spectrum makes a useful distinction between theme-specific and static colour tokens: static colours keep their value and are paired with black or white content to preserve contrast.

The point is not to confiscate every hex value. It is to keep those values inside palette definitions or mark them as deliberate static assets, instead of letting accidental absolutes pass as design decisions.

Debug helps here too. If the company logo stays blue, that is expected. If a generic information icon stays blue, somebody should be able to explain why.

The idea travels#

This is not really a Tailwind trick. Figma variable modes put multiple values behind the same variable and explicitly support adding a third colour theme without reapplying fills. Native apps, email templates, reports, slide decks, and multi-brand systems all benefit from the same separation between a role and its current value.

GitHub's Primer supports nine colour themes, including dimmed, high-contrast, and colour-vision variants. Its components do not memorize nine palettes. They use functional tokens such as bgColor-default and let the active theme supply the colour.

The medium changes. The useful question does not: does this element know its colour, or does it know its role?

Three is not a magical number. It is the first number that makes pairwise exceptions feel as expensive as they really are.

If the product only needs light and dark, ship light and dark. Keep Debug private, bright, and demanding. Its job is to prove that the first two are a system rather than a collection of matching guesses.

Ship two. Build three.