Skip to content
Chapter 18Lesson 5

Dark mode with semantic tokens

Build a Tailwind dark theme the shadcn way, naming colors as semantic tokens in globals.css and letting the theme swap their OKLCH values.

A real SaaS ships light and dark themes from day one. Users expect the choice, and many arrive with their OS already set to dark, so every component has to render correctly in both themes.

The first lesson of this chapter, “Utility-first on JSX,” introduced dark: as a variant gate: dark:bg-slate-800 applies a background only in dark mode. The obvious move is to give every color a dark: twin. This lesson shows what experienced teams reach for instead: a small set of role-named colors whose values the theme decides, not the component.

One boundary first. This is the Tailwind and CSS half of dark mode: how you define the colors and how the swap happens. It stops the moment a .dark class lands on <html> — putting it there, reading the OS preference, running before the page paints, is the next lesson, “Theme switching.” So throughout, assume something off screen has already set .dark on the root, and watch what that does to your styles.

The obvious first attempt: pair every color utility on a card with a dark: override so it looks right in both themes.

<div className="bg-white text-slate-900 border-slate-200 dark:bg-slate-800 dark:text-slate-50 dark:border-slate-700">
</div>

It works: light mode is a white surface with dark text, and once .dark lands on an ancestor it flips to a dark-grey surface with light text. The cost is what compounds across a real codebase:

  • Maintenance. Every component re-derives the dark palette by hand. The card, the dialog, the sidebar each know their own dark colors — no shared source, just the same decision remade in every file.
  • Drift. The card uses slate-800, but was the dialog slate-800 or slate-900? Nobody remembers or checks, and the two surfaces quietly diverge. Multiply that across every color on every component and the dark theme slowly stops looking designed.
  • Weight. Six to ten extra utilities per element. The class string doubles, and half of it is bookkeeping unrelated to what the component is.

The root issue is that the component should not know the dark palette at all. slate-800 is a value, a specific grey, and a component that hard-codes it owns a design decision it has no business owning. What it wants to say is “paint me the card surface, with the text that belongs on a card”: name a role and let the theme supply the value.

Each card renders twice: on a light page (top) and under a .dark ancestor (bottom). The starter cards carry only their light colors, so the bottom row stays white. Add the matching dark: utilities to make it go dark and match the target. That is three duplicate colors per card; notice how much you type just to keep up.

Target
Your output LIVE

That fix is three dark: utilities per card, repeated on every other surface. The rest of the lesson removes that cost.

A semantic token names a color by what it’s for, not what it is. Instead of white and slate-800, you name roles: background, card, primary, border, muted. A theme is one set of values for those roles — same names in light and dark, different values.

That dissolves all three costs at once. A component references card, never a grey. Each role’s value lives in one place, so nothing drifts. And the class string says only what the element is, with no dark: bookkeeping, because the role never changes between themes, only its value.

This course uses the role set from shadcn/ui, so it’s worth learning the names. The key idea is the pairing convention: every surface token has a matching foreground token — card with card-foreground, primary with primary-foreground, muted with muted-foreground. Because a surface and its foreground are tuned together, text stays legible in both themes, so you never strand light-mode text on a dark-mode card.

The full role set, grouped to scan:

Surfaces

background, card, popover, each with a matching -foreground. The page, raised panels, and floating menus.

Brand and intent

primary, secondary, accent, destructive, each with a matching -foreground. Calls to action, secondary actions, highlights, and dangerous actions.

Supporting

muted (with muted-foreground for low-emphasis text), border, input, ring. Hairlines, field outlines, and focus rings.

The payoff, side by side — the naive card from the last section rewritten in token form.

<div className="bg-white text-slate-900 border-slate-200 dark:bg-slate-800 dark:text-slate-50 dark:border-slate-700">

Two palettes hard-coded into one component. Every new component repeats the exercise, and the darks drift apart.

These utilities should look familiar. You’ve written bg-card, text-foreground, and border-border since this chapter’s first lesson, referencing tokens already defined for you. That was the consumer side; this lesson is the definition side.

The everyday skill is picking the right role. For each blank below, choose the token that names what the element is for.

Each blank is a color role, not a value. Pick the token that names what the element is for, and remember a surface and its text are a pair. Pick the right option from each dropdown, then press Check.

<article className="bg-card text-card-foreground rounded-lg border border-___">
<p className="text-sm text-___">Updated 3 hours ago</p>
<h3 className="font-semibold">Quarterly report</h3>
<button className="bg-primary text-___ rounded-md px-3 py-1.5">
Open
</button>
<button className="bg-destructive text-___ rounded-md px-3 py-1.5">
Delete
</button>
</article>

You know the model. Now the syntax: where the two value sets live, and how .dark swaps between them. Start with the simplest form that works, then the one you’ll copy from shadcn.

Recall from “CSS-first config” that a --color-* token in @theme mints the matching utilities: --color-card is what makes bg-card and text-card exist. So the most direct way to define a palette is to put the light values in @theme and let a .dark block override the same names.

globals.css
@theme {
--color-background: oklch(1 0 0);
--color-foreground: oklch(0.145 0 0);
--color-card: oklch(1 0 0);
--color-card-foreground: oklch(0.145 0 0);
}
.dark {
--color-background: oklch(0.145 0 0);
--color-foreground: oklch(0.985 0 0);
--color-card: oklch(0.205 0 0);
--color-card-foreground: oklch(0.985 0 0);
}

These colors are written in OKLCH, oklch(L C H), where the first number is lightness from 0 (black) to 1 (white). It suits dark mode because lightness is perceptually uniform: a 0.1 drop darkens by the same visible amount at any hue, and dark mode is mostly “lower the lightness, keep the hue.” So oklch(1 0 0) is pure white, oklch(0.145 0 0) near-black, and 0 0 a neutral grey. Authoring a real palette comes in a later lesson, “OKLCH, color-mix(), and the alpha syntax”; here you’re only reading these values.

Now the mechanism behind the swap. The utility bg-background does not compile to a fixed color, but to this:

.bg-background {
background-color: var(--color-background);
}

It reads a variable. So when .dark sits on an ancestor and re-points --color-background to the dark value, the cascade resolves it to the dark color, and every utility reading it follows. Change one variable and everything depending on it re-themes at once. The class string never moves, because the class was never the color: it was always a reference, and the theme owns the variable.

Scrub through the swap:

The component writes
<div className=" bg-card ">

The component asks for a role. The class string is fixed from here; only the variable behind bg-card changes.

The component writes
<div className=" bg-card ">
Tailwind emits
.bg-card { background-color: var(--card) }

The hinge: bg-card compiles to a variable read, var(--card), not a color.

Light theme
:root { --card : oklch(1 0 0) }
Rendered node
Quarterly report
bg-card

Light theme: :root sets --card to white, so the cascade resolves the variable to a white surface.

Dark theme
<html class="dark">
.dark { --card : oklch(0.205 0 0) }
Rendered node
Quarterly report
bg-card

Dark theme: .dark on <html> wins the cascade, so --card resolves to a dark surface. Same node, same class; only the winning value changed.

Step A works, but shadcn’s globals.css looks different. The palette lives in :root and .dark as plain CSS variables (--card, not --color-card), and a separate @theme inline block bridges them into Tailwind’s color tokens:

@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) * 0.8);
--radius-sm: calc(var(--radius) * 0.6);
}

Defines what dark means as a selector. The next section unpacks it; for now, it is what tells Tailwind to honor the .dark class.

@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) * 0.8);
--radius-sm: calc(var(--radius) * 0.6);
}

:root is the light theme: one plain variable per role, in OKLCH. Note --card, not --color-card; these aren’t Tailwind tokens yet, just plain variables holding the palette. --radius is a non-color token along for the ride.

@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) * 0.8);
--radius-sm: calc(var(--radius) * 0.6);
}

.dark is the dark theme: same names, different values. The entire dark theme is this block of overrides.

@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) * 0.8);
--radius-sm: calc(var(--radius) * 0.6);
}

@theme inline is the bridge. Each Tailwind color token (--color-card) maps to the plain variable (var(--card)). This is what makes bg-card exist, and the --radius-* rows derive sizes from --radius with calc().

1 / 1

The word that needs explaining is inline, the reason this two-layer shape works.

A plain @theme defines its tokens as variables on :root. So --color-card: var(--card) lands on :root, and bg-card compiles to background-color: var(--color-card), which references --card. The catch is where that inner var(--card) gets read: CSS resolves it in the scope where --color-card lives, :root, where --card still holds its light value. When .dark overrides --card deeper in the tree, the override never reaches --color-card — the hop already happened up at :root. Dark mode quietly does nothing.

inline fixes the scope. It emits the var(--card) reference directly into the utility, so bg-card compiles to background-color: var(--card). Now the lookup happens on the element itself, where .dark is in scope, so the cascade picks the dark value and the swap reaches every utility.

Was Step A wrong, then? No. Its --color-card: oklch(...) also compiles to a var(--color-card) read that .dark can override, so both forms reach the same cascade. Step B is canonical because it is the exact shape you copy from shadcn, and it separates the palette (:root and .dark, the design) from the binding (@theme inline, the Tailwind mapping): a designer can rewrite the .dark block without touching the binding, which is also what lets scalar tokens like --radius drive calc()-derived utilities. Write Step B.

Common failures and their causes:

  • A token isn’t showing up as a utility. It is missing from @theme inline, or the --color- prefix is wrong.
  • Colors don’t change when .dark is present. Either the @custom-variant dark line (next section) is wrong or absent, or you bridged a var(--…) token through a plain @theme instead of @theme inline, so the reference resolved in the wrong scope.
  • The --color- prefix leaked into :root. :root and .dark hold plain variables like --card; the --color- prefix appears only in @theme inline.

The whole globals.css, in order:

The full globals.css, in order
globals.css
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
/* …foreground, card, popover, primary, secondary, muted, accent, border, input, ring, destructive… */
}
.dark {
--background: oklch(0.145 0 0);
/* …dark overrides for the rest… */
}
@theme inline {
--color-background: var(--background);
/* …one --color-* per role, plus the --radius-* derivations… */
}

Working the model by hand is the fastest way to trust it. In the playground below, the sliders set the lightness of a surface and its foreground in OKLCH, and the toggle flips between two value sets, like .dark flipping which block wins. Drag the surface lightness down and the card darkens; the contrast chip reads the live ratio between surface and text. Watch what it takes to keep that chip passing across light and dark: that is why a surface ships paired with its own foreground.

Slide the lightness of a surface and its foreground; toggle light/dark to swap the value set. The chip is the live contrast ratio.

A token pair has to pass contrast in both themes — which is exactly why a surface carries its own -foreground, rather than borrowing one global text color tuned for a single background.

One line in that file does the switching:

@custom-variant dark (&:is(.dark *));

@custom-variant, from the config lesson, defines a new variant prefix. Here dark gets the selector &:is(.dark *): “this element, when it has a .dark ancestor.” That does two jobs. It powers any explicit dark: utility you write, and because the whole .dark subtree matches, it is the switch under which your .dark { … } variable overrides take hold on every descendant. Without it, Tailwind wouldn’t treat the .dark class as meaningful at all.

The class itself goes on <html>, the next lesson’s job. For now, assume something sets .dark on the root, and this line is what Tailwind reads to react.

Sources vary on one detail. shadcn ships &:is(.dark *); Tailwind’s default is &:where(.dark, .dark *). The difference is specificity: :where() contributes zero, keeping dark rules maximally overridable, while :is() contributes its argument’s. The course uses :is() because that is what shadcn ships and you paste. Specificity is a later chapter.

Order matters: @custom-variant must be defined before anything uses it, and the bridge must come after the palette it reads.

When to keep dark: inline instead of a token

Section titled “When to keep dark: inline instead of a token”

The token model is for systematic color: the surfaces, text, and borders every component shares. One-offs belong inline:

  • a shadow that is softer or absent in dark (dark:shadow-none),
  • a hero gradient that flips direction or palette between themes,
  • an image or illustration overlay that only appears in dark.

Promoting one of these to a named token pollutes the palette with a single-use value nothing else references.

Tokens also compose cleanly with the state variants from the previous lesson, “DOM-state variants”: state and theme are independent axes. A field that turns red on an invalid value writes one theme-agnostic string:

<input className="border-input aria-invalid:border-destructive aria-invalid:bg-destructive/10" />

destructive resolves correctly in both themes, and the aria-invalid: gate fires from DOM state the field already tracks. One class string carries through every theme and every state, because both read off the same token set.

Where does each of these belong — a token or an inline dark:?

You’re adding dark-mode support across the app. Which of these adjustments earn a semantic token, rather than a one-off inline dark:? Select all that apply.

The surface color shared by the settings panel, the command menu, and every dialog — currently bg-white dark:bg-slate-800, repeated on each.
The dimmer text used for “last edited” labels and helper hints, which you’ve already typed as dark:text-slate-400 on the billing page and the profile page.
The blurred glow behind the marketing splash image, faded out in dark mode on that one screen.
The dark:shadow-none you add to the onboarding card so it sits flat on the dark canvas — nowhere else in the app.

The model holds in one sentence: components ask for a role; the active theme answers; flipping the .dark class re-answers every question at once. It scales well past light versus dark:

Non-color tokens

The swap works for --radius, shadows, and font sizes too — the --radius-* rows in @theme inline already did this.

Hue-shifted darks

A dark theme can shift hue, not just lower lightness. Each theme sets its values independently, so nothing ties the dark palette to the light one.

Beyond light and dark

Brand themes, high-contrast mode, and per-tenant theming extend the same way, through a data-theme attribute and more value sets.

Everything here assumed .dark was already on <html>. The next lesson, “Theme switching,” wires up next-themes, sets the class before the page paints to avoid a flash of the wrong theme, and builds the toggle.