Every visual decision in Proper UI — color, spacing, radius, shadow, type scale — is a CSS variable defined once in theme.css and consumed by
utility classes. Components never hard-code a color or a size, so re-theming the whole library is a matter of editing variables, not hunting through
component files.
Token layers
The token system has three layers, each built on the one before it:
- Base palette — the raw
--color-brand-50…--color-brand-950scale, plus Tailwind's built-in neutral/red/yellow/green/blue/… scales. This is the only layer you touch for a re-brand. - Utility colors —
--color-utility-{brand,gray,blue,indigo,purple,pink,orange,…}-{50…900}, used by badges, tags, and charts that need a wider palette than the semantic layer exposes. - Semantic tokens — role-based variables like
--color-bg-primary,--color-text-secondary,--color-fg-brand-primary, and--color-border-tertiary, consumed directly by components asbg-primary,text-secondary,text-fg-brand-primary,border-tertiary. Semantic tokens reference the base and utility layers viavar(), so changing the brand palette cascades automatically — including into dark mode.
| Prefix | Role | Example utility |
|---|---|---|
text-* | Typography color | text-primary, text-tertiary |
fg-* | Icon / graphic color | text-fg-quaternary |
bg-* | Surface color | bg-primary, bg-brand-solid |
border-* | Stroke color | border-secondary, ring-primary |
Never reach for a raw palette utility (bg-neutral-100, text-purple-600) inside a component — pick a semantic token by the role you need, not by the
color it happens to render as today.
Editing the tokens
The CLI doesn't generate a brand scale for you — init writes theme.css with the default palette (or fetches it from the registry), and re-theming
is a matter of editing that file directly:
/* theme.css */
@theme {
--color-brand-25: #fcfaff;
--color-brand-50: #f9f5ff;
--color-brand-100: #f4ebff;
/* … 200 through 900 … */
--color-brand-950: #1a0966;
}
Keep the contrast relationships that the semantic layer expects: 600 is the primary interactive color on a light background, 700 is its hover
state, 50/100 are tints for subtle surfaces, and 300 supplies the default focus-ring tone. Do not edit the semantic mappings themselves (bg-*,
text-*, fg-*, border-*) — they're already wired to cascade correctly.
Scoping a theme to part of your app
Because every semantic token resolves through var(--color-brand-*), you can re-theme a single subtree by overriding the brand variables on a wrapper
element instead of forking components:
.theme-productb {
--color-brand-50: #eff6ff;
--color-brand-600: #2563eb;
--color-brand-700: #1d4ed8;
/* …through 950 */
}
<div className="theme-productb">
<Button color="primary">Uses the Product B brand</Button>
</div>
Everything inside .theme-productb — buttons, badges, focus rings, links — resolves to the overridden scale, while the rest of the page keeps the
default brand. This is the same mechanism dark mode uses (see Dark mode), just scoped to a class you choose instead of .dark-mode.