Skip to content
Chapter 18Lesson 5

Dark mode via semantic tokens

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

A real SaaS ships a light theme and a dark theme from day one. Users expect to choose, and many arrive with their OS already set to dark. That leaves you with a concrete decision: how does a single component render correctly in both themes?

You already know one tool that looks like the answer. The first lesson of this chapter, “Utility-first on JSX,” introduced dark: as a variant gate, where dark:bg-slate-800 applies that background only in dark mode. The obvious move is to add a dark: version of every color on every element. This lesson talks you out of that as your default and shows you what experienced teams reach for instead: a small set of named colors whose values the theme decides, not the component.

There is one boundary to set first. This lesson covers the Tailwind and CSS half of dark mode: how the colors are defined and how the swap happens. It stops at the moment a .dark class lands on the <html> element. What puts the class there is the next lesson, “Theme switching with next-themes”: the toggle, the code that reads the OS preference, and the code that runs before the page paints. So throughout this lesson, assume some switch out of view has already put .dark on the root, and watch what that does to your styles. You won’t build a working toggle here.

Two themes, and the naive way to get there

Section titled “Two themes, and the naive way to get there”

Here is the obvious first attempt. You have a card, and you want it to look right in both light and dark, so you pair every color utility with a dark: override:

<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. In light mode the card is a white surface with dark text; once .dark lands on an ancestor, it flips to a dark-grey surface with light text. The trouble is the cost, which compounds across a real codebase:

  • Maintenance. Every component re-derives the dark palette by hand. The card knows its dark colors, the dialog knows its dark colors, the sidebar knows its dark colors. There is 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, nobody checks, and the two surfaces quietly diverge. Multiply that by 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 in length, and half of it is bookkeeping that has nothing to do with what the component is.

The deeper problem is that the component should not know the dark palette at all. slate-800 is a value, a specific grey, and the moment a component hard-codes it, that component owns a design decision it has no business owning. What the component actually wants to say is “paint me the card surface, with the text color that belongs on a card.” It should ask for a role and let the theme answer. That shift, from naming the value to naming the role, is the whole lesson.

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

Target
Your output LIVE

The fix is three dark: utilities per card (dark:bg-slate-800 dark:text-slate-50 dark:border-slate-700), and you would repeat that same exercise on the dialog, the sidebar, the table row, and every other surface in the app. That is the cost the rest of the lesson removes.

A semantic token is a color named for what it’s for rather than what it is. Instead of white and slate-800, you name roles: background, card, primary, border, muted. A theme is then just one set of values for those role names. Light is one set, dark is another. Same names, different values.

That single move dissolves all three costs at once. The component references card, never a grey. Each role’s value lives in exactly one place, so nothing can drift. And the class string says only what the element is, with no dark: bookkeeping at all, because the role does not change between themes, only its value does.

The role set this course uses is the one shadcn/ui ships, the component library you’ll build a real product surface on later in this unit, so it’s worth learning the actual names. The key idea in the set is the pairing convention: every surface token has a matching foreground token for the text and icons that sit on it. card pairs with card-foreground, primary with primary-foreground, muted with muted-foreground. The pair is the unit. Because the designer tunes a surface and its foreground together, the text stays legible on that surface in both themes, so you never put light-mode text on a dark-mode card by accident.

Here is the full role set, grouped so you can scan it rather than memorize a flat list:

Surfaces

background, card, popover, each with a matching -foreground. The page itself, 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.

Here is the payoff, made concrete: the naive card from the last section rewritten in token form, side by side.

<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 dark colors drift apart over time.

Those token utilities should look familiar. You’ve been writing bg-card, text-foreground, and border-border since the first lesson of this chapter, where every sample painted itself with role names rather than with bg-blue-500. That was deliberate. You’ve been writing the consumer side of this model all along, referencing tokens that were already defined for you. This lesson is the definition side, where those tokens come from.

The everyday skill, then, 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 — remember a surface and the text on it 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 for the syntax: where the two value sets live, and how .dark swaps from one to the other. We’ll build it in two steps, the simplest form that works first and then the form you’ll actually copy from shadcn, so the production shape arrives as a small, motivated change rather than a wall of boilerplate.

Recall from “CSS-first config in globals.css” earlier in this chapter that a --color-* token in @theme mints the matching utilities, so --color-card is what makes bg-card and text-card exist. The most direct way to define your palette, then, is to put the light values in @theme and let a .dark block override those same variable 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);
}

Those colors are written in OKLCH , written as oklch(L C H), where the first number is lightness from 0 (black) to 1 (white). You’ll see it throughout shadcn’s palette, so it’s worth one sentence on why it suits dark mode. Lightness in OKLCH is perceptually uniform, meaning a 0.1 drop looks like the same amount of darkening at any hue. That is exactly the property you want for dark mode, where the move is mostly “lower the lightness, keep the hue.” So oklch(1 0 0) is pure white and oklch(0.145 0 0) is near-black, and the 0 0 means no chroma and no hue, a neutral grey. That is all you need here. Authoring a real palette is its own lesson later in this unit, “OKLCH, color-mix(), and the alpha syntax”; right now you’re only reading these values.

Now for the mechanism that makes the swap work; it is the idea the rest of the lesson builds on, so it is worth reading slowly. The utility bg-background does not compile to a fixed color. It compiles 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 CSS cascade resolves that variable to the dark color, and bg-background follows, along with every other utility that reads the same variable. You don’t re-style anything. You change one variable, and everything that depends on it re-themes at once. The component’s class string never moves because the class was never the color: it was always a reference to a variable, and the theme owns the variable.

That indirection is what the whole model rests on, and it’s worth watching the swap happen rather than taking it on faith. Scrub through this:

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

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

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

The utility compiles to a variable read, not a color. This is the hinge of the whole mechanism: bg-card points at var(--card).

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 is on <html> and wins the cascade, so --card now resolves to a dark surface. Same node, same class; only which --card value won has changed.

The class never changed across those four panels. The only thing that changed was which --card value won the cascade.

Step A works, and you understand it fully now. But the file you’ll actually copy from shadcn looks a little different, and the difference is worth understanding rather than pasting blind. In shadcn’s globals.css, the palette lives in :root and .dark as plain CSS variables (--card, not --color-card), and a separate @theme inline block bridges those plain variables 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);
}

Line one defines what dark means as a selector. We unpack it in the next section; for now, know that 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 CSS variable per role, in OKLCH. Note --card, not --color-card; these are not Tailwind tokens yet, just plain variables holding the palette. --radius is a non-color token coming 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: the same variable names, different values, and nothing else. The entire dark theme is just 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 as a utility, and the --radius-* rows show non-color tokens deriving sizes from --radius with calc().

1 / 1

The one part that deserves a real explanation is the word inline, because it is subtle and it is the reason the two-layer shape works at all.

A plain @theme defines its tokens as variables on :root. So --color-card: var(--card) would put that line on :root, and bg-card would compile to background-color: var(--color-card). That is a reference to --color-card, which is itself a reference to --card. The catch is where that inner var(--card) gets read. CSS resolves it in the scope where --color-card lives, which is :root, where --card still holds its light value. So when .dark overrides --card deeper in the tree, that override never reaches --color-card: the hop from --color-card to --card already happened up at :root, in the light scope, before .dark was ever in play. The result is that dark mode quietly does nothing.

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

The rule is short:

This raises a fair question: if Step A worked, was it wrong? No. Step A’s --color-card: oklch(...) also compiles to a var(--color-card) read in the utility, which .dark can override too, so both forms reach the same cascade. Step B is the canonical one for two reasons. First, it is the exact shape you’ll copy from shadcn, so learning it now means the file makes sense when you paste it. Second, it cleanly separates concerns: :root and .dark are the palette, the design, while @theme inline is the Tailwind binding. A designer can rewrite the whole .dark block without touching the binding, and the binding is what lets scalar tokens like --radius drive calc()-derived utilities. For both reasons, write Step B.

A few common failures and what causes each:

  • A token isn’t showing up as a utility. It is missing from @theme inline, or the --color- prefix is wrong. As in the last config lesson, check that the token starts with a real namespace.
  • 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 and .dark never reached it.
  • The --color- prefix leaked into :root. :root and .dark hold plain variables, like --card. The --color- prefix appears only inside @theme inline. Mixing the two is an easy typo to make.

You’ve been seeing these blocks as fragments. Here is the whole globals.css, in order, so you’ve seen it assembled once:

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, exactly like .dark flipping which block wins. Drag the surface lightness down and the card darkens; the contrast chip reads the live ratio between the surface and its text. Notice what it takes to keep that chip passing as you move between 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.

The lesson from the chip is that a token pair has to pass contrast in both themes. A surface carries its own -foreground precisely so it can clear that bar, rather than borrowing one global text color that can only be tuned for a single background.

One line in that file does the actual switching, and it’s worth isolating so it doesn’t stay mysterious:

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

You met @custom-variant in the config lesson as the way to define a new variant prefix. This is its most valuable use. It defines dark as a variant whose selector is &:is(.dark *), which reads as “this element, when it has a .dark ancestor.” Two things follow from that one definition. It powers any explicit dark: utility you still choose to write, and we’ll see below where that is still the right call. And because the entire .dark subtree matches the selector, it is the switch under which your .dark { … } variable overrides take hold on every descendant. In short, the directive is what makes Tailwind treat the .dark class as meaningful at all.

The class itself goes on <html>, and putting it there is the next lesson’s job. Here, you only need to hold one thing in mind: something sets .dark on the root, and this line is what Tailwind reads in order to react to it.

You’ll see one small variation between sources, so it is worth naming before it confuses you. shadcn ships &:is(.dark *). Tailwind’s own default is &:where(.dark, .dark *). The only difference is specificity: :where() contributes zero specificity, while :is() contributes its argument’s. In practice, :where() keeps dark rules maximally overridable, which is the safer default, while :is() is what shadcn picked and therefore what you’ll paste. Learn :is() as the canonical shape for this course, and just know that :where() exists as the zero-specificity alternative. Why specificity matters is the subject of a later chapter, so for now you can leave the choice here.

The order of these blocks matters: @custom-variant has to be defined before anything uses it, and the bridge has to come after the palette it reads. Put the file in the correct order:

Order the blocks of globals.css from top to bottom. Two constraints decide it: a directive must be defined before anything uses it, and the bridge can only read a palette that already exists. Drag the items into the correct order, then press Check.

@import "tailwindcss"; — pulls in Tailwind first, so everything below has it
@custom-variant dark (&:is(.dark *)); — defines what dark means, before any block relies on it
:root { … } — the light palette: one plain --token per role
.dark { … } — the dark overrides: same names, swapped values
@theme inline { … } — the bridge, last: maps --color-* to the --tokens defined above

When per-utility dark: still earns its weight

Section titled “When per-utility dark: still earns its weight”

Most of this lesson has steered you away from dark:, so it is worth marking the cases where it is still the right tool, before you over-correct. The token model is for systematic color: the surfaces, text, and borders that every component shares. But some adjustments are genuinely one-off, and forcing those into the global palette is its own mistake. A few that legitimately 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.

For these, an inline dark: is the right call. Promoting a one-off shadow to a named token would pollute the palette with a single-use value that no other component will ever reference, which is clutter dressed up as a system.

So here is the actual rule, the threshold to keep in your head:

That is the same rule from earlier in the chapter, where a repeated arbitrary value is a signal the theme should grow, now pointed at dark mode specifically.

The token model buys you one more thing: it composes cleanly with the state variants from the previous lesson, “Variants that read DOM state.” 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 the DOM state the field already tracks. One class string carries through every theme and every state, because both read off the same token set. That is the payoff of keeping color in tokens: the two axes never tangle.

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.

Hold onto one sentence: components ask for a role; the active theme answers; flipping the .dark class re-answers every question at once. That is the entire mental model, and it scales further than light versus dark.

Non-color tokens

The same swap works for --radius, shadows, even font sizes; anything can differ by theme or surface. The --radius-* rows in @theme inline already did exactly this.

Hue-shifted darks

A dark theme can shift hue, not just lower lightness. The value sets per theme are fully independent, so this costs nothing extra.

Beyond light and dark

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

Now for the handoff. Everything in this lesson assumed .dark was already on <html>. It isn’t yet: nothing here sets it, since you never built a toggle, read the OS preference, or imported React. This lesson covered what changes: the colors, the tokens, and the directive that makes the swap fire. The next lesson, “Theme switching with next-themes,” covers what flips the switch: wiring up next-themes, setting the class before the page paints so there’s no flash of the wrong theme, and building the toggle the user actually clicks.