Skip to main content

Theming

The design system behind NachUI — semantic tokens, the theme contract, and how to bend both to your brand.

NachUI has no hardcoded colors. Every component paints itself with semantic tokens — bg-card, text-muted-foreground, border-rule — and those tokens are CSS custom properties you own. Change a variable and the whole system moves with it, in both light and dark mode, without touching a single component.

How it works

Theming runs through three layers. Understanding them is most of what you need.
1. A CSS custom property holds the value. It is declared on :root, and overridden inside .dark.
1:root {
2 --primary: oklch(54.6% 0.245 262.881);
3}
4
5.dark {
6 --primary: oklch(61.6% 0.245 262.9);
7}
2. @theme inline maps it into Tailwind's color space. This is what turns a variable into utilities.
1@theme inline {
2 --color-primary: var(--primary);
3}
3. Tailwind generates the utilities. Once --color-primary exists, you get bg-primary, text-primary, border-primary, ring-primary, and every other color utility for free.
1<button className="bg-primary text-primary-foreground">Deploy</button>
Because step 2 uses inline, the utility resolves the variable at use time rather than baking in a value. That is why flipping .dark on the <html> element re-colors the entire app with no re-render.
Dark mode is class-based, declared once:
1@custom-variant dark (&:where(.dark, .dark *));
A token is not a color
Tokens are named by role, not by hue. --destructive means "this action is dangerous", not "this is red". Keeping the naming semantic is what lets a theme swap every value and still make sense.

The foreground convention

Every surface token pairs with a -foreground token for the content that sits on it. The surface keeps the bare name; its content gets the suffix.
  • --card is the surface, --card-foreground is the text on that surface
  • --primary is the fill, --primary-foreground is the label on that fill
1<div className="bg-card text-card-foreground">
2 <button className="bg-primary text-primary-foreground">Confirm</button>
3</div>
Follow the pairing and contrast takes care of itself: a theme author only has to keep each pair legible, and every component that uses the pair inherits that guarantee.

Token reference

Surfaces

The stacking order of the interface, from the page backwards to floating layers.
TokenUtilityRole
--backgroundbg-backgroundThe page canvas
--cardbg-cardPanels and cards raised off the canvas
--popoverbg-popoverFloating layers: dropdowns, popovers, tooltips
--surface-mutedbg-surface-mutedRecessed areas: table headers, hovered rows, wells
Each of --background, --card and --popover has a matching -foreground.

Text

TokenUtilityRole
--foregroundtext-foregroundPrimary text
--muted-strongtext-muted-strongSecondary text that still needs to be read
--muted-foregroundtext-muted-foregroundTertiary text: captions, hints, disabled labels
--muted-strong sits deliberately between the other two. Long-form paragraphs use it so body copy reads softer than headings without dropping to caption contrast.

Interactive

TokenUtilityRole
--primarybg-primaryThe main action on a screen
--secondarybg-secondarySupporting actions
--accentbg-accentHover and highlight states on neutral surfaces
--mutedbg-mutedInert filled areas
--inversebg-inverseDeliberately flipped contrast, e.g. tooltips
All five have a -foreground counterpart.

Structure and focus

TokenUtilityRole
--borderborder-borderDefault component borders
--border-interactiveborder-border-interactiveBorders on controls the user can act on
--inputborder-inputForm field borders
--ringring-ringFocus rings
--ruleborder-ruleEditorial hairlines: section dividers, page rails
--grid-colorDot and grid backdrops, used directly in CSS
--border and --rule often hold the same value, but they are separate on purpose: one is component chrome, the other is page structure. Restyling your layout rules should not touch every card.

Feedback

Each of the four feedback hues — destructive, warning, success, info — ships a five-role scale rather than one color. This is what lets a solid button and a soft alert share a hue without one of them failing contrast.
SuffixUtility exampleRole
(none)bg-destructiveSolid fill, for buttons
-foregroundtext-destructive-foregroundContent on that solid fill
-texttext-destructive-textThe hue as readable text on a pale surface
-surfacebg-destructive-surfaceTinted background for alerts and badges
-borderborder-destructive-borderBorder matched to that tinted background
Solid — the fill carries the meaning:
1<Button variant="destructive">Delete account</Button>
Soft — the tint carries it, and the text stays legible:
1<div className="bg-destructive-surface text-destructive-text border-destructive-border border">
2 This action cannot be undone.
3</div>
Reaching for text-destructive on a pale background is the common mistake — that is the button fill, and it is tuned for white text, not for reading. Use -text instead.

Overlay, elevation and code

TokenUtilityRole
--overlaybg-overlayScrim behind dialogs and drawers
--elevation-sm / -md / -lgshadow-sm / -md / -lgThe shadow scale
--codebg-codeCode block surface
--code-plain, --code-keyword, --code-string, --code-function, --code-number, --code-tag, --code-comment, --code-punctuationSyntax colors, read directly by the code block
Shadows are themeable values, not fixed drop shadows: a dark theme raises their opacity, because a shadow tuned for a white canvas disappears on a near-black one.

Radius

One base token drives every corner in the system.
1:root {
2 --radius: 1.5rem;
3}
The scale derives from it, so changing the base reshapes the whole app proportionally:
UtilityDerivationWith the default 1.5rem
rounded-smcalc(var(--radius) - 0.75rem)12px
rounded-mdcalc(var(--radius) - 0.5rem)16px
rounded-lgvar(--radius)24px
rounded-xlcalc(var(--radius) + 0.5rem)32px
rounded-2xlcalc(var(--radius) + 1rem)40px
For sharper corners across the board, lower the base:
1:root {
2 --radius: 0.5rem;
3}
--radius is the one token declared only in :root — a corner has no light and dark variant.

Typography

Four font slots are wired into Tailwind, so font-sans, font-heading, font-serif and font-mono all resolve to whatever you load.
1@theme inline {
2 --font-sans: var(--font-sans);
3 --font-heading: var(--font-heading);
4 --font-serif: var(--font-serif);
5 --font-mono: var(--font-mono);
6}
In Next.js, next/font defines those variables for you:
1import { Bricolage_Grotesque } from 'next/font/google';
2
3export const fontHeading = Bricolage_Grotesque({
4 subsets: ['latin'],
5 variable: '--font-heading',
6 display: 'swap',
7});
Then put the generated class on <body> and headings pick it up automatically.

Why OKLCH

Every token is written in OKLCH:
1oklch(54.6% 0.245 262.881);
2/* ↑ ↑ ↑
3 | | hue, 0–360°
4 | chroma, 0 = gray
5 lightness, 0% = black, 100% = white */
It is perceptually uniform, which matters when you build a scale. In HSL, hsl(60 100% 50%) and hsl(240 100% 50%) claim the same lightness while yellow reads far brighter than blue. In OKLCH, equal lightness looks equally light — so holding the L value steady across hues gives you a feedback palette that is genuinely balanced, instead of one where the warning badge screams and the info badge whispers.
It also reaches colors sRGB cannot express, and degrades gracefully on older browsers. oklch.com is a good place to pick values.

Customizing your theme

Override a token

Edit the variable in your CSS. Change it in both blocks — a value set only in :root will leak into dark mode.
1:root {
2 --primary: oklch(65% 0.25 150);
3 --primary-foreground: oklch(100% 0 0);
4}
5
6.dark {
7 --primary: oklch(72% 0.22 150);
8 --primary-foreground: oklch(14.5% 0 0);
9}
Nothing else to update. Every button, link and focus ring that references --primary follows.

Add a new token

Declare it in both modes

1:root {
2 --brand: oklch(62% 0.19 310);
3 --brand-foreground: oklch(99% 0 0);
4}
5
6.dark {
7 --brand: oklch(70% 0.17 310);
8 --brand-foreground: oklch(14.5% 0 0);
9}

Expose it to Tailwind

1@theme inline {
2 --color-brand: var(--brand);
3 --color-brand-foreground: var(--brand-foreground);
4}

Use the utilities

1<Badge className="bg-brand text-brand-foreground">New</Badge>
Skipping step 2 is the usual failure: the variable exists, but bg-brand never gets generated and the element renders unstyled.

The theme contract

A theme is not a handful of colors — it is the complete token set, declared in both :root and .dark. That full list lives in theme-contract.ts and is enforced by a test.
The rule exists because of how the cascade fails here. A theme that omits --success-surface does not error; it silently inherits the value from the base palette, and you end up with a green tint from the default theme bleeding into your brand colors. Missing tokens are invisible until someone opens the one component that uses them.
So when you write a theme, work down the contract:
  • Surfacesbackground, card, popover, surface-muted and their foregrounds
  • Interactiveprimary, secondary, accent, muted, inverse and their foregrounds, plus muted-strong
  • Structureborder, border-interactive, input, ring, rule, grid-color
  • Feedback — all five roles for each of destructive, warning, success, info
  • Scrim and elevationoverlay, elevation-sm, elevation-md, elevation-lg
  • Codecode plus the eight syntax tokens
  • Radius--radius, in :root only

Switching themes

nachui init fetches the available themes and writes the one you pick into your global stylesheet, between a /* NachUI Tokens */ marker.
1npx nachui init
Re-running it replaces that block and leaves the rest of your CSS alone, so you can try a theme on and change your mind. To go back to a palette you edited by hand, keep your overrides below the marker — anything after it survives.
For how the .dark class gets applied and persisted, see Dark Mode.
Found something to improve?

Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.

Create an Issue
Ctrl+I