Theming
How tokens resolve, and where to change them.
The token layer has three stages, and the order they load in is not arbitrary.
@import "./tokens/color.css"; /* 1. primitives register in Tailwind's namespaces */
@import "./themes/base.css"; /* 2. the semantic layer resolves them to roles */
@import "./tokens/themes.css"; /* 3. the bridge maps roles back to utilities */Never use a primitive directly
A component asks for a role, not a value. bg-surface-sunken, not
bg-neutral-100; text-text-accent, not text-[#F4521B]. The role is what
survives a theme change — the value is what a theme is allowed to move.
| Prefer | Over |
|---|---|
bg-surface, bg-surface-raised, bg-surface-sunken | bg-white, bg-gray-50 |
text-text-primary, text-text-secondary, text-text-muted | text-black, text-gray-500 |
border-border-subtle, border-border-strong | border-gray-200 |
text-text-accent | text-[#F4521B] |
Products differ by theme, not by component
A product that needs to look different changes themes/<product>.css and
nothing else. If you find yourself editing a component to make one app look
different, the theme layer is missing something — that gap is the bug, not the
component.
<html data-theme="admin"> {/* light */}
<html data-theme="admin" class="dark"> {/* dark */}Dark mode is a block inside each theme file, guarded so source order can't make the light block outrank it.
Strict mode
Once an app has finished migrating, you can delete Tailwind's default scales
entirely, so bg-blue-500 and text-sm stop resolving:
@import "tailwindcss";
@import "../design-system/tokens/strict.css"; /* between the two */
@import "../design-system/index.css";The build then fails on anything left behind, which is the point. If the failure list is long, that list is the ad hoc styling the migration was for — work through it rather than disabling the guard.
Adding a token
Two files, always: the CSS variable in tokens/, and its description in
lib/tokens.ts. Every swatch and table in these docs is generated from the
second one, so a token without a description shows up here as a blank.