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.

app/globals.css
/* 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

Perceptual lightness is the first number, so a palette can be lightened or darkened for dark mode by moving one value without the hue drifting. The same edit in hex is guesswork.

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 blue
emberwarm amber
verdantemerald green
plumviolet
slateneutral graphite

Select 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.

app/globals.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);
}
  1. 01

    Keep every name

    Components read the names, not the values. Deleting one leaves whatever used it unstyled — add names freely, remove none.
  2. 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 .dark block rather than reusing the value.
  3. 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

The theme generator takes one brand colour, applies all three rules for you across both modes, previews the result on real components and hands back exactly this block — containing only what you changed, so tokens added in a later release still reach you.

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-foreground and its siblings were added — lands in your app on the next npm 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.

pairs
/* 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-foreground

The 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

Before them the components wrote 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.

app/globals.css
/* 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.

page.tsx
{/* 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

Overlays that render in a portal — dialogs, toasts, popovers — attach near the end of body and so read the root's theme, not the section they were opened from.