Theming
How a palette, a mode and a shape scale resolve into the classes the components are written in.
How it resolves
Three layers, and a component only ever sees the third. Colour is never written into a component file.
/* 1 · The palette declares the variables. */
[data-theme="sapphire"] {
--primary: oklch(0.55 0.20 258);
--background: oklch(0.985 0.004 255);
--foreground: oklch(0.21 0.03 265);
}
/* 2 · @theme inline hands them to Tailwind. */
@theme inline {
--color-primary: var(--primary);
--color-background: var(--background);
--color-foreground: var(--foreground);
}
/* 3 · The component is written in the utilities. */
<button className="bg-primary text-primary-foreground" />Because the mapping in step two is the only place the two vocabularies meet, changing a colour never means touching a component — and a component can never hold a colour the theme does not know about.
Why oklch
The 5 palettes
Each declares the same variable names twice — once for light, once for dark. Switching one is a change of values, never of selectors.
sapphiredefault · cool blueemberwarm amberverdantemerald greenplumvioletslateneutral graphiteSelect one by setting data-theme on the root element. The palette menu in this site's header does exactly that and stores your choice in localStorage.
Your own palette
The ones that ship are starting points, not a brand. Overriding them is the expected path, and it is one block of CSS.
/* After @import "kipui/theme.css", so yours wins.
Redeclare only what you are changing. */
:root {
--primary: oklch(0.62 0.19 265); /* your brand */
--primary-foreground: oklch(0.99 0 0);
--ring: oklch(0.62 0.19 265); /* usually matches --primary */
}
.dark {
--primary: oklch(0.68 0.18 265); /* lighter, so it holds on dark */
--ring: oklch(0.68 0.18 265);
}- 01
Keep every name
Components read the names, not the values. Deleting one leaves whatever used it unstyled — add names freely, remove none. - 02
Set both modes
A brand colour that reads well on white is usually too dark on a dark background. Raise the lightness for the.darkblock rather than reusing the value. - 03
Move --ring with --primary
The focus ring is its own variable so it can differ, but it normally tracks the primary. If you change one and not the other, focus states will look like a mistake.
Or do it by eye
Why only the changes
Redeclare the tokens you moved and leave the rest alone. It looks like a shortcut and is really a decision about what happens six months from now.
- New tokens still reach you. A release that adds one — as
--success-foregroundand its siblings were added — lands in your app on the nextnpm update. Copy all 31 and you have frozen the set at the day you pasted it. - Your intent stays readable. A four-line block says “we changed the brand and the radius”. A thirty-line block says nothing, and the next person cannot tell which of those values were decisions.
- Merges stop hurting. A palette tweak upstream conflicts with a full copy and applies cleanly under an override.
This is what the generator emits by default. It marks each token it will write and gives you the count above the code block; if nothing differs from the defaults, it writes nothing, which is the correct output for a theme that has not moved.
The foreground pairs
Every fill that carries text has a matching text token. This is the list to walk after any colour edit.
/* Every fill that carries text has a partner. Move one, check the other. */
--primary --primary-foreground
--secondary --secondary-foreground
--accent --accent-foreground
--destructive --destructive-foreground
--success --success-foreground
--warning --warning-foreground /* dark text — warning fills are pale */
--info --info-foreground
--card --card-foreground
--popover --popover-foregroundThe generator shows a live ratio for each pair and flags anything under 4.5:1, the WCAG AA threshold for body text. When a fill sits in the middle of the lightness range no text colour can reach that ratio, so the derivation moves the fill a few percent rather than handing you an unreadable button.
Why these tokens exist
text-white on bg-success, which measures 3.3:1 and could not be fixed from a theme at all. A palette with a pale success colour had no way to make its own buttons legible.Shape and elevation
Radius, shadow and motion are palette-independent — the same in every theme, and each behind a single knob.
/* Same idea for the shape scales — one value each. */
:root {
--radius: 0.625rem; /* the whole rounded-* scale derives from it */
--shadow-strength: 1; /* 0 removes every shadow */
--spacing: 0.25rem; /* the grid every p-*/m-* step multiplies */
--motion: 1; /* 0 makes every animate-* instant */
}One edit changes the whole library
Set --radius to 0 and every component squares off, because the whole scale is calc() from that one value. Set --shadow-strength to 0 and elevation disappears without any component losing its layout. The full list of variables is on the Tokens page.
Scoping a theme
Because the palette is an attribute rather than a build-time config, it nests.
{/* A subtree on its own palette. Nothing else on the page changes. */}
<section data-theme="plum">
<Button>Still uses --primary — plum's</Button>
</section>Variables cascade, so data-theme on any element re-themes that subtree and nothing else. This is what makes a themed preview, a per-tenant section or a side-by-side comparison possible without a second copy of the CSS.
One exception to keep in mind
body and so read the root's theme, not the section they were opened from.