Theming
Monochrome by default, one brand knob, light and dark from the same roles.
Blank ships black and white. Ink on neutral greys, with color reserved for
what it means: status (success, warning, info, danger), data, and your
brand. Every color a component uses is a role (background, primary,
brand, muted-foreground) defined as a CSS variable, once for light
(:root) and once for dark (.dark). A new look is an edit to those
variables, never to a component.
The fastest way to make one is the theme configurator: pick a brand color, tint the greys, set the radius and the font, and copy only the variables that changed.
Light and dark
Dark mode is a dark class on <html>. In Next.js, use next-themes:
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>Tailwind's dark: variant follows the same class.
The brand knob
brand is the one role meant to be turned. Focus rings, checked boxes,
radios and switches, selected days, the active tab line, progress and slider
fills all use it. Out of the box it is the same ink as primary, so nothing
on the page is colored until you say so.
Set it per theme. Dark mode wants a lighter step of the same color, and the foreground is whichever of white or ink reads better on the fill:
:root {
--brand: #2563eb;
--brand-hover: color-mix(in oklab, var(--brand) 85%, var(--background));
--brand-subtle: color-mix(in oklab, var(--brand) 10%, var(--background));
--brand-foreground: var(--white);
}
.dark {
--brand: #5182ef;
--brand-hover: color-mix(in oklab, var(--brand) 85%, var(--background));
--brand-subtle: color-mix(in oklab, var(--brand) 16%, var(--background));
--brand-foreground: var(--neutral-950);
}ring, focus-ring, item-selected and item-selected-border are mixed
from --brand, so they follow without being listed.
primary stays separate on purpose. It is the action color, the filled
button, and it stays ink, so a screen full of buttons does not glow in the
brand color.
Tint the neutrals
Every surface, border and line of text reads from one grey ramp,
--neutral-50 to --neutral-950, shared by both themes. Override the ramp
in :root and light and dark warm up (or cool down) together:
:root {
--neutral-100: #f6f5f4;
--neutral-300: #e3e0dd;
--neutral-600: #6a635c;
--neutral-900: #191715;
/* …the other steps from the configurator */
}Keep the tint faint. A few points of red or blue in the mid greys is enough; white and near-black stay almost pure.
Radius
Every radius derives from --radius (default 0.625rem, 10px), so the
system rounds together: cards sit at --radius + 6px, dialogs at
--radius + 14px. Pill controls (buttons, inputs, tabs, badges) stay round
at any value. The full scale is on Tokens.
:root {
--radius: 0.875rem;
}Fonts
The token sheet reads two variables: --font-geist-sans for font-sans and
--font-geist-mono for font-mono. Load Geist with next/font under those
names, as in Installation. To use
another family, load it under the same variable:
const sans = Inter({ subsets: ["latin"], variable: "--font-geist-sans" })Override any role
The brand is the knob; everything else can still move. Re-point a role in
both blocks so light and dark stay a pair, and prefer that over classes on
components: a button that reads bg-primary follows a new --primary, one
you gave a palette class does not.
Inverted surfaces
A dark menu on a light page is a surface, not a second palette. Put
data-inverted on a popover-like element and it re-points the popover roles
at their inverted counterparts:
<SelectContent inverted>…</SelectContent>