tailwind token system
why
arbitrary values scattered through utilities make the visual system unownable: a theme change means hunting every literal color and radius across the codebase, and drift creeps in wherever a value was copied rather than named. semantic tokens keep one owner for every visual role, so theming, rebranding, and consistency become a token-file edit instead of a global search-and-replace.
rule
Tailwind utilities express layout and composition; semantic tokens express the visual system. a component selects a semantic token, never an arbitrary color or radius value that only makes sense in one place.
ownership
- shared CSS owns semantic variables and Tailwind mappings.
- components own their presentation through declared variants.
- the innermost wrapper containing final information or controls owns padding.
separate tokens from composition
tokens answer "what visual role is this?"; utilities answer "how is this instance laid out?" a surface token can change across themes while a grid, stack, or alignment utility remains local composition. this separation makes a visual correction flow to every semantic consumer without turning a component into a global CSS exception.
| concern | owner | example decision |
|---|---|---|
| canvas, surface, text, border, action, destructive roles | shared token source | one semantic variable per role, with light/dark values where supported. |
| component intent, density, interaction state | component variant | map named variants to tokens and utilities. |
| local layout, order, gap, responsive arrangement | consuming composition | choose Tailwind utilities on the parent layout/wrapper; do not override an authored child's internal classes. |
| final content/control inset | innermost meaningful wrapper | own padding once; decorative wrappers do not add a second inset. |
decision process
| shortcut | failure | selected direction |
|---|---|---|
| use a palette value in a component | a later theme change cannot find the semantic consumer | use the role token. |
| add a global selector for one exceptional component | style ownership becomes non-local and cascade-dependent | add a component variant only if callers share a named intent. |
| let every wrapper add spacing | nested cards acquire accidental density and uneven hit areas | decide the final content wrapper that owns padding. |
| create a new token for a one-off visual adjustment | the token system becomes a list of exceptions | keep genuinely local composition local. |
verification
inspect a representative surface in every supported theme and interaction state. a role change should be traceable from token to component variant to rendered element; no raw palette or one-off global selector should be required to explain its appearance. manually change one semantic token value and confirm it updates every consuming component; a component that does not change is using raw palette or one-off CSS instead of the semantic role.
do not
- stack padding across decorative wrappers.
- use raw palette colors where a semantic token exists.
- add one-off global CSS to change a component instance.
good and bad
bad
// Card.tsx<div className="bg-slate-500 text-slate-50 p-4">Content</div>// StatusBadge.tsx<span className="bg-slate-500 px-2 py-1">Active</span>// ErrorAlert.tsx<div className="bg-slate-500 text-white">Error message</div>
three components use the same palette value for different semantic purposes:
content surface, status indicator, error container. when the audit says
"success should use emerald, error should use red," you must find every
hardcoded slate-500, infer its intent, and decide case by case. a new
callout copies bg-slate-500 from three places, making the fourth use
undefined. a theme refresh changes --slate-500 for contrast, but only Card
updates correctly; the others happen to look right anyway. audit finds success
green in one place, accidental teal in another, broken red in a third.
inconsistency becomes unfixable: you don't know which uses are intentional.
good
// tokens.css:root {--surface: hsl(0 0% 98%); /* background layers and containers */--foreground: hsl(0 0% 10%); /* primary and secondary text */--success: hsl(142 71% 45%); /* positive status and actionable confirmation */--error: hsl(0 84% 60%); /* destructive and error states */}// Card.tsx — owns its purpose<div className="bg-surface text-foreground p-4">Content</div>// StatusBadge.tsx — owns its semantic role<span className="bg-success text-foreground px-2 py-1">Active</span>// ErrorAlert.tsx — owns error semantics<div className="bg-error text-surface">Error message</div>
each component selects its semantic role. a design audit changes --success
once and all status components update; a new status component reads the shared
tokens and picks bg-success with confidence; a theme refresh changes error
color once and all error states update together. visual semantics stay
consistent because named tokens own them, not scattered palette copies.