The default theme is black and white on purpose — a default, not a brand. This walkthrough takes a whole app from there to your own identity by redefining custom properties in one stylesheet. No component is touched, no class is overridden, and the result is live at the bottom of this page.
All overrides go in the same place: your globals.css, after the theme you
start from.
@import "tailwindcss";
@import "@voila.dev/ui/styles/globals.css";
@import "@voila.dev/ui/styles/themes/default.css";
/* your overrides below */If one of the shipped themes is already close — indigo, iris, sand,
emerald, olive — start from that one instead and you will have fewer
properties to set.
Step 1: the brand colour
--primary is every default button, progress fill and active state.
--ring is the focus ring; set it to the same hue so keyboard focus reads as
branded rather than grey. Always set the -foreground partner, it is the
text that sits on the surface.
:root {
--primary: oklch(0.55 0.21 145);
--primary-foreground: oklch(0.99 0 0);
--ring: oklch(0.55 0.21 145);
}Values are OKLch, a perceptual colour space: lightness is the first number, so "the same colour but lighter" is a one-number change rather than a guess. Paste a hex value into oklch.com to convert your existing brand colour.
Step 2: the dark theme
A colour tuned for white backgrounds usually sinks on dark ones. Redefine the
pair inside .dark, lighter and with the foreground flipped:
.dark {
--primary: oklch(0.72 0.18 145);
--primary-foreground: oklch(0.16 0.01 145);
}If you skip this step the light values still apply in dark mode; they are merely worse. Two minutes here is the difference between a rebrand and a skin.
Step 3: status colours
Only if your brand has opinions about them. --destructive, --success and
--warning are already sensible; most rebrands leave them alone. If yours
clashes, for instance a red brand next to the default destructive red, shift
the status hue, not the brand.
:root {
--destructive: oklch(0.58 0.22 15);
--destructive-foreground: oklch(0.99 0 0);
}Step 4: radius
--radius is a single number the whole corner scale derives from
(--radius-sm through --radius-xl are calculated off it), so one line
takes every button, card and input from soft to sharp:
:root {
--radius: 0.25rem; /* sharp */
}Step 5: typography
The theme names two families, --font-sans for body copy and
--font-heading for display text. Point them at yours and load the fonts
however your framework prefers; the tokens only name them.
:root {
--font-sans: "Your Sans", ui-sans-serif, system-ui, sans-serif;
--font-heading: "Your Display", var(--font-sans);
}Step 6: check contrast
The default palette targets WCAG AA. Your overrides are your responsibility:
run each -foreground against its surface through a contrast checker
(4.5:1 for body text, 3:1 for large text and UI). OKLch makes the fix
obvious, since lightness is the first number in the value.
Before and after
The two panels below are the same markup. The second sits inside a wrapper that redefines four properties inline, exactly what the stylesheet above does app-wide:
const rebrand = {
"--primary": "oklch(0.55 0.21 145)",
"--primary-foreground": "oklch(0.99 0 0)",
"--ring": "oklch(0.55 0.21 145)",
"--radius": "0.25rem",
} as CSSProperties;
<div style={rebrand}>{/* every component inside follows */}</div>;Same markup, four properties apart
Default palette
Four properties later
Because custom properties cascade, this wrapper trick is also how you scope a sub-brand to one route, preview a client's colours in a settings page, or white-label per tenant with a single inline style.
Own it
Two files tell the whole story:
@voila.dev/ui/src/styles/tokens/colors.css for every role that exists, and
@voila.dev/ui/src/styles/themes/default.css for its light and dark values.
Copy the theme into your repo if you would rather edit values than override
them — the sibling themes are exactly that, a copy that overrides a dozen
properties.