Theming
How Nim UI's design tokens work, and how to re-tint the palette for your own brand
Nim UI's design tokens are a single CSS @theme block — packages/ui/src/tokens.css — not a tailwind.config.js. There is no JS theme object standing between your override and the component; every component references a token by variable name (var(--color-primary-500)), so redefining that variable re-tints every component that reads it, with no rebuild of the library itself.
This guide covers the token/palette layer only. For per-component style overrides (className, CVA variants, the cn() utility), see Customization. For the full settings surface — typography scale, spacing, border radius, build tooling — see Configuration; this page does not repeat it.
How it works
Tailwind CSS v4 is configured in CSS. There is no tailwind.config.js to extend and no content array to maintain — v4 discovers class names in your source itself, and a leftover config file is simply ignored unless you point at it explicitly with @config (covered in Configuration).
Nim UI's tokens live in one place, packages/ui/src/tokens.css, and the file states its own scope in a header comment: pure tokens only — an @theme block, keyframes, and nothing else. No @import "tailwindcss", no @custom-variant, no @font-face. That restraint is deliberate: the same file is imported by two different stylesheets that each need to add those missing pieces differently —
packages/ui/src/styles.css, compiled to the./stylesexport you import as@import '@nim-ui/components/styles';- this documentation site's own
app/global.css
— and a token file can't declare @custom-variant dark or load fonts without picking one consumer's answer for both.
Every color value in the file is OKLCH, not RGB or hex, and there is no <alpha-value> indirection to wire up. A fractional opacity suffix on a color utility doesn't need a special token shape to support it — the compiled bundle resolves it with color-mix(in oklab, var(--color-primary-500) 10%, transparent) against the same variable, wherever a component asks for a tinted version of a token (for example border-color: color-mix(in oklab, var(--color-primary-500) 30%, transparent) on a focus-adjacent border). A shade itself looks like this:
--color-primary-500: oklch(0.534 0.030 248.2);That single declaration does two things at once: it makes bg-primary-500 / text-primary-500 / border-primary-500 exist as utility classes, and it puts a real custom property, --color-primary-500, in scope wherever the utility is used. Overriding the variable is what the recipe below relies on.
Override a token
Start from the standard two-import setup (Configuration covers it in full):
@import 'tailwindcss';
@import '@nim-ui/components/styles';To re-tint the kit, add your own @theme block after that second import, in the same file:
@import 'tailwindcss';
@import '@nim-ui/components/styles';
@theme {
--color-primary-500: oklch(0.58 0.21 300);
--color-primary-600: oklch(0.50 0.19 300);
}The order matters. @import '@nim-ui/components/styles' is a compiled stylesheet — it already declares --color-primary-500 inside @layer theme, and every component reads the color through var(--color-primary-500) rather than a baked-in literal. Your @theme block compiles into the same @layer theme, and CSS resolves the custom property from whichever declaration comes later in the cascade — so the override has to come after the import, not before it. Configuration documents the same ordering for its color/typography/radius examples; this is the same mechanism applied to a full rebrand rather than one or two values.
Because primary-* is reserved in this kit for focus rings, links, and selection — never a decorative fill, per the design system's own contract — retinting it changes every focus indicator, link color, selected row, chart fill, spinner, progress bar, slider range, and radio dot across all components, without touching a single component file. It also means the new value inherits that scale's accessibility obligation: the shipped focus indicator is required to clear 3:1 contrast against white / neutral-50 / neutral-100 in light mode and neutral-800 / neutral-900 / neutral-950 in dark mode, in both themes. That requirement is enforced against the shipped scale only — if you replace it, re-check contrast yourself against those same surfaces before shipping.
Token reference
| Category | Variables | Notes |
|---|---|---|
| Primary (Steel) | --color-primary-50 … --color-primary-950 | Reserved for focus rings, links, selection. Never a fill. |
| Neutral | --color-neutral-50 … --color-neutral-950 | Text, surfaces, borders. |
| Success | --color-success-50 … --color-success-950 | |
| Warning | --color-warning-50 … --color-warning-950 | |
| Error | --color-error-50 … --color-error-950 | The scale is named error in the CSS; the docs surface it as "Danger" to match the destructive variant name components actually expose. There is no --color-danger variable. |
| Info | --color-info-50 … --color-info-950 | |
| Radius | --radius-md (0.5rem) | The only radius token the kit's own source declares. rounded-lg, rounded-full, etc. resolve from whatever Tailwind's own default theme supplies, untouched — nothing in packages/ui/src redeclares them. The compiled bundle happens to emit --radius-lg: 0.5rem, the same value as the kit's --radius-md, so rounded-md and rounded-lg currently render identically — redefine --radius-lg alongside --radius-md if you want them to stay in step after customizing. |
| Shadow | --shadow-soft, --shadow-panel, --shadow-control | Elevation tokens — cards, overlays, and controls respectively. Not Tailwind's stock shadow-sm / shadow-lg names. |
| Motion | --duration-fast / -normal / -slow, --ease-out / -in / -in-out, plus a --animate-* entry for each fade/scale/slide/accordion keyframe | One --animate-* variable per transition the kit ships — fade, scale, slide (all four directions, in and out), and accordion. |
For the exact OKLCH value of every shade in every scale, see Colors — that page's swatches are generated from this same tokens.css and are kept in sync with it by comment convention.
Two categories are conspicuously absent, on purpose:
- Spacing. There are no
--spacing-*tokens. Spacing comes entirely from Tailwind's own scale (base--spacing: 0.25rem); see Spacing for the full mapping. - Typography.
tokens.cssdeclares no--font-*variables at all. The three font families (--font-sans,--font-mono,--font-display) live one layer up, in whichever stylesheet importstokens.css—packages/ui/src/styles.cssfor the published kit, this site's ownglobal.cssfor these docs — and the kit ships zero font bytes either way. See Typography.
@nim-ui/tailwind-config is not a preset
The packages/tailwind-config package exports a single path, @nim-ui/tailwind-config/tokens, which is a plain JavaScript object mirroring the same values as tokens.css — for tooling that cannot read CSS, namely the MCP server and the library's own animation test guards. A test (token-parity.test.js) fails the build if the two ever disagree. It is not a Tailwind preset, plugin, or config file, and a consumer app has no reason to import it. The @theme block in your stylesheet is the entire wiring; there is no JS config step in the loop.
Dark mode
Dark mode is class-based, not prefers-color-scheme-based, and the binding lives in the compiled stylesheet, not in tokens.css:
@custom-variant dark (&:where(.dark, .dark *, [data-theme="dark"], [data-theme="dark"] *));This line is declared in packages/ui/src/styles.css and compiled into the ./styles bundle you import. It means dark: utilities activate under a .dark class anywhere in the ancestor chain (or a [data-theme="dark"] attribute — a hook this documentation site's own local @custom-variant doesn't use, since its Fumadocs toggle only ever sets .dark). Without it, Tailwind v4's dark: variant would instead follow the OS prefers-color-scheme media query and ignore any in-app toggle.
As Configuration documents — and this is worth repeating here because it is the opposite of what a Tailwind v3 background would predict — this binding is not something you configure or override. @import '@nim-ui/components/styles' brings in an already-compiled stylesheet in which @custom-variant has already been expanded into concrete :where(...) selectors on every rule; redeclaring @custom-variant dark in your own entry stylesheet, before or after that import, changes nothing. There is no darkMode setting to flip. If you want dark mode to follow the OS instead of a class, keep the .dark class and drive it from matchMedia in JavaScript — see Dark Mode for a worked toggle, and the ThemeProvider pattern there for a larger app.
The palette itself does not carry separate light/dark values — there is one OKLCH value per shade in tokens.css, used identically regardless of theme. Dark-mode contrast comes from components pairing a different shade number under the dark: variant (text-primary-600 dark:text-primary-400, bg-neutral-50 dark:bg-neutral-900), not from a shade's own value swapping under .dark. So if you redefine --color-primary-500 as shown above, that new value applies in both themes equally, exactly like the shipped scale — and every color utility you add of your own still needs its dark: counterpart pairing a different shade, the same convention every shipped component follows.
What's Next?
- Configuration — the full
@themesurface: typography, spacing, radius, and the build-tool wiring around it - Customization — override individual component styles with
className, CVA, andcn() - Colors — every shade of every scale, generated from
tokens.css - Dark Mode — toggling, system-preference detection, and the theme provider pattern
- Typography — the three font families and how to load them