Theming and tokens

Override the CSS custom properties in @voila.dev/ui/styles/themes/default.css to rebrand the whole system without forking a single component.

Styling splits in two, and the split is the whole idea:

FileJob
styles/globals.cssThe contract: which tokens exist, and the Tailwind class names (bg-primary, text-muted-foreground) that read them. Also the package's Tailwind sources.
styles/themes/<name>.cssThe values: what --primary actually is, in light and in dark.

You import the contract once and exactly one theme. Six themes ship:

Theme
defaultBlack and white. Charts, badges and the destructive/success/warning trio keep their hue — everything else is achromatic.
indigoThe issue-tracker look (Linear).
irisPayments, electric blurple on navy-tinted greys (Stripe).
sandPaper-warm workspace (Notion, Craft).
emeraldDeveloper platform (Supabase).
oliveFintech: cream, sage charts, ink-black buttons (Mercury, Ramp).

Try them live with the palette button in this site's header — it is switching the same stylesheet you would import.

The shipped palettes are defaults, not brands. Every colour, radius and font is a CSS custom property, and every component reads them through Tailwind's semantic class names. Redefine the properties and the whole system moves with them — no component is forked, no class is overridden.

The one rule

Override tokens, never component classes. If you find yourself writing .button { background: … } or passing className="!bg-blue-500", the token you actually wanted is missing from your override list. Find it and set it there; every other component that uses it stays consistent for free.

How to override

Redefine the properties after the theme you are starting from. Set both modes.

src/styles/globals.css
@import "tailwindcss";
@import "@voila.dev/ui/styles/globals.css";
@import "@voila.dev/ui/styles/themes/default.css";
 
:root {
	--primary: oklch(0.52 0.19 24);        /* your brand red */
	--primary-foreground: oklch(0.99 0 0);
	--ring: oklch(0.52 0.19 24);           /* focus rings follow the brand */
	--radius: 0.375rem;                    /* squarer corners */
}
 
.dark {
	--primary: oklch(0.68 0.17 24);        /* lighter, so it holds up on dark */
	--primary-foreground: oklch(0.16 0 0);
}

Values are OKLch — a perceptual colour space, which is why "the same colour but lighter" is a change to one number rather than a guess. Hex and rgb() work too if you prefer.

The token map

Each role is a color + -foreground pair: the surface, and the text that goes on it. Set both or you will eventually ship white-on-yellow.

Surfaces and text

TokenUsed by
--background / --foregroundThe page itself
--card / --card-foregroundCard, StatCard, raised panels
--popover / --popover-foregroundPopover, DropdownMenu, Select, Combobox
--muted / --muted-foregroundSecondary text, placeholders, quiet fills
--accent / --accent-foregroundHover and highlight states

Actions and status

TokenUsed by
--primary / --primary-foregroundDefault buttons, progress fills, active states
--secondary / --secondary-foregroundThe secondary button variant
--destructive / --destructive-foregroundDestructive buttons, error text, invalid fields
--success / --success-foregroundSuccess banners and badges
--warning / --warning-foregroundWarning banners and badges

Product surfaces

--brand (blue) and --highlight (orange) exist for apps with two distinct workspace identities — think a marketplace with a supply side and a demand side. If yours has one identity, ignore them. If it has two, rename is not needed: just set them to your two colours.

Lines, focus, radius

TokenUsed by
--borderEvery border, via the base layer
--inputForm control borders
--ringFocus rings
--radiusThe corner radius scale (sm/md/lg/xl derive from it)

--radius is a single number the rest derive from, so one line takes the whole system from soft to sharp:

:root { --radius: 0.25rem; }  /* sharp */
:root { --radius: 1rem; }     /* very round */

Charts and sidebar

--chart-1--chart-5 are the categorical series colours used by @voila.dev/ui/chart. --sidebar* is a parallel surface set so the sidebar can sit at a different elevation from the page without every sidebar component taking an override.

Badges

--badge-slate through --badge-rose are a closed, enumerated multi-colour set for catalogue-style chips, where the colour is data rather than meaning. It is a deliberate exception to "no ad-hoc colour tokens": the names are fixed, and each has a dark override so chips stay legible in both themes.

Badges are data-coloured, not semantic

CardioMobilityStrengthRecovery

Typography

The default theme expects two families. Point them at your own:

:root {
	--font-sans: "Your Sans", ui-sans-serif, system-ui, sans-serif;
	--font-display: "Your Display", var(--font-sans);
}

Load the fonts however your framework prefers — next/font, a <link>, a self-hosted @font-face. The tokens only name them.

A worked example

A minimal but complete rebrand, both themes, roughly twenty lines:

:root {
	--primary: oklch(0.55 0.21 145);
	--primary-foreground: oklch(0.99 0 0);
	--ring: oklch(0.55 0.21 145);
	--radius: 0.5rem;
	--font-sans: "Söhne", ui-sans-serif, system-ui, sans-serif;
}
 
.dark {
	--background: oklch(0.16 0.01 145);
	--foreground: oklch(0.97 0 0);
	--card: oklch(0.21 0.01 145);
	--primary: oklch(0.72 0.18 145);
	--primary-foreground: oklch(0.16 0.01 145);
	--border: oklch(0.29 0.01 145);
}

What moves when --primary and --radius move

Checking contrast

Overriding a -foreground without checking it against its surface is the one way to ship something genuinely broken. The default palette targets WCAG AA (4.5:1 for body text, 3:1 for large text and UI). Run your own values through a contrast checker before shipping — OKLch makes the fix obvious, since lightness is the first number.

Next: Project setup.