Skip to main content

ns-ui / theming

Ten tokens, two themes.

Every component reads color from CSS custom properties already in scope, never a hardcoded hex — in markup or in canvas/SVG draw code. This is the full list, what each one controls, and how to change what they resolve to.

The tokens

Values as this site defines them (app/globals.css). A consuming project can set these to anything — the names and what they mean are the contract, not the hex values below.

TokenLightDarkControls
--background#ffffff#0a0a0aPage background.
--foreground#171717#edededBody text.
--surface#fafafa#171717Elevated surfaces — cards, code blocks, inset panels.
--border#ebebeb#2e2e2eHairlines and dividers.
--ns-muted#4d4d4d#8f8f8fSecondary text — descriptions, captions, eyebrows.
--ns-accent#006bff#006bffThe brand blue. Links, active states, primary actions.
--ns-accent-hover#0059d1#0059d1Hover/pressed state for anything using --ns-accent.
--error#ea001d#ff6369Destructive actions, invalid state, failure text.
--success#2d7a2d#47a447Positive/confirmation state.
--warning#7a5200#f5a623Caution state — never a brand accent.

--ns-accent and --ns-accent-hover are the same value in both themes — the brand blue is deliberately theme-invariant, everything else here is not.

Two layers, not one

A component's markup never writes var(--ns-muted) directly — it uses a Tailwind utility, text-ns-muted. That utility only exists because it's registered in a second block, separate from the custom property itself:

Layer 1 — the raw custom properties

:root {
  color-scheme: light;
  --background: #ffffff;
  --foreground: #171717;
  --surface: #fafafa;
  --border: #ebebeb;
  --ns-muted: #4d4d4d;
  --ns-accent: #006bff;
  --ns-accent-hover: #0059d1;
  --error: #ea001d;
  --success: #2d7a2d;
  --warning: #7a5200;
}

.dark {
  color-scheme: dark;
  --background: #0a0a0a;
  --foreground: #ededed;
  --surface: #171717;
  --border: #2e2e2e;
  --ns-muted: #8f8f8f;
  --error: #ff6369;
  --success: #47a447;
  --warning: #f5a623;
}

Layer 2 — the Tailwind utility mapping

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-surface: var(--surface);
  --color-border: var(--border);
  --color-ns-muted: var(--ns-muted);
  --color-ns-accent: var(--ns-accent);
  --color-ns-accent-hover: var(--ns-accent-hover);
}

Tailwind v4's @theme inline generates text-*/bg-*/border-* utilities from --color-* variables, resolved at build time. Copy only the :root/.dark block into a project and the custom properties exist, but text-ns-muteddoesn't — Tailwind never sees a reason to generate it. The component renders with no error and no color: unstyled ink. Both blocks have to land together.

Light and dark

Dark mode is a .dark class on <html>, not a media query — every token above is redeclared inside .dark and the cascade does the rest. color-scheme is set alongside it (light in :root, dark in .dark) so native browser chrome — the scrollbar, a native <select> panel, autofill backgrounds — follows the same theme instead of staying light against a dark page.

Every component in the registry is verified in both themes, and the gate hard-fails a component whose light and dark render as byte-identical screenshots — that only catches a component that ignored theming entirely, not one that merely looks wrong in light. Whatever theme you ship, look at both yourself before calling it done.

Overriding a value

Change what a token resolves to by redeclaring it in your own :root/.dark — components read the variable, not a specific value, so a different --ns-accent re-themes every installed component that uses it, with no component code to touch. This is also what running shadcn add does automatically the first time you install a component that needs a token you don't have yet — it merges that token into your CSS file rather than failing or silently doing nothing.

Why --ns-* and not --muted / --accent

Stock shadcn already ships tokens named --muted and --accent, but as neutral surface colors — a muted background and a subtle hover background, each with its own -foreground pair for text on top of them. This registry needed different things under similar-sounding names: --ns-muted is a text color (secondary body text, captions), and --ns-accent is the brand blue used as a foreground/ink color on links and active states, not a background.

Reusing the stock names would mean a component's text-accent resolves to whatever neutral hover-surface color a project's own shadcn setup already defined for --accent — silently, with no error, because the property exists and just holds the wrong kind of color for the job. Namespacing under --ns-* keeps the two vocabularies from ever aliasing each other: a project can define both shadcn's own tokens and ns-ui's side by side with no collision.