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, whether 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.

Three tokens with a narrower job than their name

Reading the right variable is not the same as using it for the right job. These three are the ones this registry gets wrong most often. Every ratio below is a WCAG contrast figure computed from the values in the table above.

--border is a separator, not a fill. In light theme it measures 1.19:1 against --background, a hairline and nothing more. Fill a shape with it, stroke a chart with it, or draw canvas ink in it and the result is invisible in light while looking correct in dark, where it reaches 1.46:1. For a faint but legible mark, use --foreground at low alpha.

--ns-accentis interaction chrome only: buttons, links, focus rings, active states. Not an ambient highlight, not a pointer trail, not a component's climactic moment. A resting screenshot is not an interaction. Pointer highlights vary luminance, never hue.

--ns-muted is a second ink at full strength, for secondary text and captions, and it is entirely correct used that way. It is not a variable-strength wash. Its contrast ceiling is theme-dependent, 8.45:1 in light against 6.12:1 in dark, so a mid-strength wash looks acceptable in both themes today and fails in dark first the moment anyone strengthens it, in a change that never touched the component. Use it at full strength, or use --foreground at an explicit alpha where you control both ends.

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. There is 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.