Proper UI

Theming

Learn how to customize Proper UI components effortlessly to make them your own.

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:

  1. Base palette: the raw --color-brand-50 through --color-brand-950 scale (eleven steps), plus --color-neutral-*, --color-red-*, --color-green-* and --color-yellow-* — Tailwind's own default values for those four, but declared explicitly in theme.css rather than left implicit. This is the only layer you touch for a re-brand.
  2. 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.
  3. Semantic tokens: role-based variables like --color-bg-primary, --color-text-secondary, --color-fg-brand-primary, and --color-border-tertiary, consumed directly by components as bg-primary, text-secondary, text-fg-brand-primary, border-tertiary. Semantic tokens reference the base and utility layers via var(), so changing the brand palette cascades automatically, including into dark mode.
PrefixRoleExample utility
text-*Typography colortext-primary, text-tertiary
fg-*Icon / graphic colortext-fg-quaternary
bg-*Surface colorbg-primary, bg-brand-solid
border-*Stroke colorborder-secondary, border-success
ring-*Focus ring / outline ringring-brand, ring-warning_subtle
outline-*Native outline coloroutline-error, outline-success
decoration-*text-decoration-colordecoration-brand, decoration-error_subtle

Every one of those namespaces has a matching success and warning pair alongside the pre-existing error one (border-success, border-success_subtle, ring-warning, outline-warning_subtle, and so on) — see Status token families below.

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.

Status token families

border-*, ring-*, outline-* and decoration-* each expose four states — error, error_subtle, success, success_subtle, warning, warning_subtle — plus brand and brand_alt, all defined in both light and dark mode. Earlier versions of theme.css only had the error half of this set, so a success or warning panel had no matching border color and fell back to an unrelated neutral. If you add a fourth status color of your own, mirror this same shape ({status}, {status}_subtle) across all four property namespaces so it's usable everywhere error is.

decoration-* is a separate case worth calling out: Tailwind's decoration-* utility resolves against a --text-decoration-color-* theme namespace before falling back to the raw palette, the same way bg-* resolves against --background-color-* and border-* against --border-color-*. theme.css declares that namespace mirroring the text and border remaps, so decoration-brand, decoration-error, decoration-success, and decoration-warning all resolve to a real color instead of silently inheriting currentColor.

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. The full re-brand is replacing these eleven --color-brand-* lines:

/* theme.css */
@theme {
    --color-brand-50: rgb(249 245 255);
    --color-brand-100: rgb(244 235 255);
    /* ... 200 through 800 ... */
    --color-brand-900: rgb(66 48 125);
    --color-brand-950: rgb(44 28 95);
}

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.

"One file" only covers what that file declares

Every color the semantic layer resolves to has to live somewhere in theme.css, or the "edit one file" claim above breaks down the moment your neutrals or status colors aren't Tailwind's stock ones. Concretely: --color-text-primary resolves to var(--color-neutral-900), and --color-fg-success-primary resolves to var(--color-green-600) — if this product's ink, page background, hairlines, or "success" green are not Tailwind's default neutral/green scales, editing only the eleven brand lines above and shipping will leave every card, border and status color on the wrong palette, and nothing will error: CSS custom properties have no compiler, so a mismatched neutral or status ramp only shows up by eye (or by reading getComputedStyle in a live browser) — the exact way this gap was originally found.

theme.css declares --color-neutral-50 through 950, plus the red, green and yellow steps the semantic layer consumes, immediately after the brand ramp, each with a comment explaining why they're there. Re-grepping the file for every --color-* it depends on now finds a complete list. If your product's neutrals or status colors are not Tailwind's stock ramps, shadow those four scales in this same file (in addition to the eleven brand lines) and the "one file" claim holds for real.

Presets

Instead of writing the eleven brand lines by hand, start from a preset: a brand ramp, a base gray (gray, slate, zinc, neutral or stone), a radius scale (none, sm, md, lg or xl) and optional fonts. Eight ship with the library: brand (the default purple), blue, indigo, teal, green, orange, rose and slate-mono.

npx @properui/cli@latest theme list             # the shipped presets and their codes
npx @properui/cli@latest theme apply teal       # write it into your global stylesheet
npx @properui/cli@latest init --preset teal     # or apply one during setup

The theme generator builds a custom preset from any hex colour, previews real components with it in light and dark mode, and gives you the CSS, a short preset code and the matching theme apply <code> command.

A preset redeclares only primitives, inside /* properui:theme-preset */ markers that theme apply replaces in place: --color-brand-*, --color-neutral-* (every gray semantic token reads this ramp, so redeclaring it is how the base gray changes), --radius-xs to --radius-4xl, and --font-body/--font-display when set.

Where the font stack lives

--font-body and --font-display in theme.css are the sanctioned place to set this product's font stack — not a second CSS file, not an app-level override. Both default to an Inter-first fallback chain; change these two lines to swap typefaces everywhere the kit renders text:

@theme {
    --font-body: "Your Font", -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
    --font-display: "Your Font", -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
}

If you introduce a second font-stack variable elsewhere in your app (a marketing-site-only override, for example), document which one wins where — otherwise the brand ends up living in two files that don't reference each other.

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.

Import order doesn't matter

theme.css works whether you @import it before or after @import "tailwindcss". This matters because Tailwind v4 only wraps a @theme block's output in its own cascade layer when that block comes after the tailwindcss import; imported first, the same block becomes unlayered CSS instead. theme.css's dark-mode overrides are written to win either way — see Dark mode: import-order independence for the mechanism and a worked example.

Non-React projects

The same tokens ship as plain CSS in @properui/tokens: tokens.css (CSS variables on :root and .dark-mode, no Tailwind needed) for Vue, Angular, Svelte or Astro apps, theme.css for Tailwind projects in any framework, every preset as a file, and a prebuilt properui.min.css a plain HTML page can link from a CDN. Everything on this page applies to them unchanged: re-brand by redeclaring the same variables after the file you load. See Design tokens (CSS).

FAQs