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.2.
@theme inline maps it into Tailwind's color space. This is what turns a variable into utilities.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.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:
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.--cardis the surface,--card-foregroundis the text on that surface--primaryis the fill,--primary-foregroundis the label on that fill
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.
| Token | Utility | Role |
|---|---|---|
--background | bg-background | The page canvas |
--card | bg-card | Panels and cards raised off the canvas |
--popover | bg-popover | Floating layers: dropdowns, popovers, tooltips |
--surface-muted | bg-surface-muted | Recessed areas: table headers, hovered rows, wells |
Each of
--background, --card and --popover has a matching -foreground.Text
| Token | Utility | Role |
|---|---|---|
--foreground | text-foreground | Primary text |
--muted-strong | text-muted-strong | Secondary text that still needs to be read |
--muted-foreground | text-muted-foreground | Tertiary 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
| Token | Utility | Role |
|---|---|---|
--primary | bg-primary | The main action on a screen |
--secondary | bg-secondary | Supporting actions |
--accent | bg-accent | Hover and highlight states on neutral surfaces |
--muted | bg-muted | Inert filled areas |
--inverse | bg-inverse | Deliberately flipped contrast, e.g. tooltips |
All five have a
-foreground counterpart.Structure and focus
| Token | Utility | Role |
|---|---|---|
--border | border-border | Default component borders |
--border-interactive | border-border-interactive | Borders on controls the user can act on |
--input | border-input | Form field borders |
--ring | ring-ring | Focus rings |
--rule | border-rule | Editorial hairlines: section dividers, page rails |
--grid-color | — | Dot 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.| Suffix | Utility example | Role |
|---|---|---|
| (none) | bg-destructive | Solid fill, for buttons |
-foreground | text-destructive-foreground | Content on that solid fill |
-text | text-destructive-text | The hue as readable text on a pale surface |
-surface | bg-destructive-surface | Tinted background for alerts and badges |
-border | border-destructive-border | Border matched to that tinted background |
Solid — the fill carries the meaning:
Soft — the tint carries it, and the text stays legible:
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
| Token | Utility | Role |
|---|---|---|
--overlay | bg-overlay | Scrim behind dialogs and drawers |
--elevation-sm / -md / -lg | shadow-sm / -md / -lg | The shadow scale |
--code | bg-code | Code block surface |
--code-plain, --code-keyword, --code-string, --code-function, --code-number, --code-tag, --code-comment, --code-punctuation | — | Syntax 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.
The scale derives from it, so changing the base reshapes the whole app proportionally:
| Utility | Derivation | With the default 1.5rem |
|---|---|---|
rounded-sm | calc(var(--radius) - 0.75rem) | 12px |
rounded-md | calc(var(--radius) - 0.5rem) | 16px |
rounded-lg | var(--radius) | 24px |
rounded-xl | calc(var(--radius) + 0.5rem) | 32px |
rounded-2xl | calc(var(--radius) + 1rem) | 40px |
For sharper corners across the board, lower the base:
--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.In Next.js,
next/font defines those variables for you:Then put the generated class on
<body> and headings pick it up automatically.Why OKLCH
Every token is written in OKLCH:
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.Nothing else to update. Every button, link and focus ring that references
--primary follows.Add a new token
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:
- Surfaces —
background,card,popover,surface-mutedand their foregrounds - Interactive —
primary,secondary,accent,muted,inverseand their foregrounds, plusmuted-strong - Structure —
border,border-interactive,input,ring,rule,grid-color - Feedback — all five roles for each of
destructive,warning,success,info - Scrim and elevation —
overlay,elevation-sm,elevation-md,elevation-lg - Code —
codeplus the eight syntax tokens - Radius —
--radius, in:rootonly
Switching themes
nachui init fetches the available themes and writes the one you pick into your global stylesheet, between a /* NachUI Tokens */ marker.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.