Open any shadcn/ui project, look at the top of globals.css, and you will find something that looks like a color list and is actually a machine. Forty-odd CSS variables, a second block that looks almost identical to the first, a lone --radius, and a directive named @theme inline that most people scroll straight past.
That machine is the theme. Not the colors it happens to hold today, the machine. Change one number at the top and the corner radius updates across an entire scale. Add a class to the <html> element and every surface on the page inverts without a single component re-rendering. Understanding how that works is the difference between nudging hex codes until something looks right and actually owning your design system.
This post is the full anatomy. We will read the real theme this site ships, explain every layer, and finish where the payoff is: packaging the whole thing so someone installs your entire design language with one command. Because these components are source code you own, none of it is hidden behind a package boundary. It is all right there in your CSS, editable.
A theme is two sets of variables, not one
The first thing to internalize is that there are two variable layers, and they do different jobs. Here is the top of this site's actual theme, trimmed for length:
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.239 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.239 0 0);
--primary: oklch(0.321 0 0);
--primary-foreground: oklch(0.98 0 0);
--muted: oklch(0.961 0 0);
--muted-foreground: oklch(0.651 0 0);
--destructive: oklch(0.702 0.216 15.859);
--border: oklch(0.898 0 0);
--ring: oklch(0.239 0 0);
}These are the raw tokens. Plain CSS custom properties, no Tailwind involvement yet. If that were the whole story, you would write style={{ background: "var(--primary)" }} by hand everywhere, and you would get no utility classes out of it.
The second layer turns them into utilities. In Tailwind v4 you do not configure colors in a JavaScript file anymore, you declare them in CSS with the @theme directive, and Tailwind generates matching utility classes. shadcn uses the inline variant of it:
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-muted-foreground: var(--muted-foreground);
/* ...one line per token... */
}The --color-* namespace is what mints the utilities. --color-primary is why bg-primary, text-primary, and border-primary exist at all. --color-muted-foreground is why text-muted-foreground works. No entry in this block, no class.
Why inline, and why it matters
The inline keyword is not decoration. Tailwind's docs put the distinction plainly: without inline, a generated utility references the theme variable; with inline, the utility is compiled with the variable's value substituted in place. Compare the two compiled outputs of bg-primary:
/* @theme (no inline): Tailwind also emits its own --color-primary into
:root, and the utility hops through it. */
.bg-primary { background-color: var(--color-primary); }
/* @theme inline: the utility is welded straight to your raw token. */
.bg-primary { background-color: var(--primary); }The inline version collapses a redundant layer. Instead of Tailwind minting a second variable (--color-primary) that just forwards to yours (--primary), the utility points directly at the token you actually override under .dark. One source of truth, no forwarding hop.
There is also a correctness reason the shadcn setup needs it. @theme inline is the option Tailwind documents specifically for the case where your theme value references another variable that gets its real value lower in the tree, which is exactly how fonts work here: --font-sans: var(--font-manrope), where --font-manrope is set on an element by next/font. Without inline, the font utility resolves against :root, where that variable may be empty; with inline, it resolves at the element where the font actually lives.
The rule that follows from all this is the one to tattoo somewhere: never put a literal color inside @theme. The instant bg-primary compiles to a fixed oklch(...) value instead of var(--primary), no .dark override can ever reach it, and your dark mode silently does nothing. Keeping the value as var(--primary) is what preserves a single, overridable source of truth.
The second block: dark mode is the same tokens, different values
Scroll down in the same file and you meet the twin of :root. Same variable names, different numbers:
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--muted-foreground: oklch(0.708 0 0);
--border: oklch(0.275 0 0);
--ring: oklch(0.556 0 0);
}Notice what dark mode is not. It is not a parallel set of components, not a dark: prefix sprinkled across a thousand class strings, not a second stylesheet. It is the same token names holding different values, scoped under a class. Because every component reads bg-card and text-foreground (never a literal bg-white or text-neutral-900), flipping the values under .dark re-themes the entire application at once.
The .dark selector gets wired to Tailwind's dark: variant with one line at the top of the file:
@custom-variant dark (&:is(.dark *));That reads: "the dark: variant applies to any element that is a descendant of something with class dark." Put class="dark" on <html>, and both the raw .dark token overrides and every dark: utility light up together.
Why the values are OKLCH
You have seen oklch(...) in every snippet above. This is deliberate, and it is worth understanding rather than copying. Tailwind v4 moved its entire default palette from RGB hex to OKLCH, and shadcn followed. The three channels are:
- L, lightness, from
0(black) to1(white). Crucially this is perceived lightness, calibrated to the human eye. - C, chroma, saturation from
0(gray) upward. It has no fixed ceiling, but real displays top out around0.37, so that is the practical max. - H, hue, an angle from
0to360. Roughly: red near20, yellow90, green140, blue220, purple320.
The reason this beats hsl() is perceptual uniformity. HSL pretends every hue has the same available saturation and that lightness means the same thing at every hue. Human vision does not work that way. In HSL, hsl(60 100% 50%) (yellow) is blinding while hsl(240 100% 50%) (blue) is muddy and dark, despite both claiming 50% lightness. That inconsistency is a real accessibility hazard: derive an error red from a brand accent by rotating the hue, and the text contrast can silently collapse because the lightness moved with it.
OKLCH fixes this by keeping L honest. oklch(0.55 0.18 20) and oklch(0.55 0.18 255) are a red and a blue that genuinely read as the same brightness. That is why a well built token scale in OKLCH holds its contrast as you shift the hue, which matters the moment you support more than one brand color.
There is a second dividend: OKLCH can address the P3 wide gamut, which covers roughly 30 percent more colors than sRGB, many of which plain hex literally cannot encode. Your most vivid accents are no longer clamped to 1996's color space.
See the machine move
This is easier to feel than to read. Below is a self-contained miniature design system, a card, a button, an input, a badge, a swatch row, themed entirely by two variables: one OKLCH --primary and one --radius. Drag the channels. Watch every element follow from a single source, and watch the button's text auto-pick dark or light ink as the primary crosses the lightness threshold where contrast would break.
:root { --primary: oklch(0.550 0.180 255); --radius: 0.625rem; }
Upgrade your workspace
Every accent here reads from a single token. Move a slider, the whole surface follows.
Two things are worth noticing while you play. First, hue is a single number, so rebranding from blue to violet is one slider, not a find-and-replace across a codebase. Second, the neutral grays in this site's theme are just OKLCH with chroma set to 0: oklch(0.239 0 0) is a pure neutral, no hue at all. Nudge chroma up a hair and you get a "warm gray" or "cool gray" for free, the trick behind tinted, non-clinical dark modes.
The semantic layer: names that survive a redesign
The single most important convention in a shadcn theme is not a color, it is a naming rule: tokens are named for their job, not their appearance. The pairing looks like this everywhere:
| Surface token | Foreground token | Meaning |
|---|---|---|
--background | --foreground | The page itself |
--card | --card-foreground | Raised panels, cards |
--primary | --primary-foreground | The main action color |
--muted | --muted-foreground | De-emphasized areas and text |
--destructive | --destructive-foreground | Delete, danger, errors |
Each surface ships with the foreground meant to sit on it. You write bg-primary text-primary-foreground and the contrast is a decision the theme already made, not one you re-litigate at every call site. (The -foreground name is the convention for text-and-icon color; the surface token drops the suffix, so it is bg-card, not bg-card-background. See the official theming reference.)
The rule that follows is short and strict: never reach for a raw palette color inside a component. A component that says bg-neutral-900 looks fine until it lands in a light theme, a paper theme, or someone's brand fork, where it is now a black rectangle nobody asked for. The same component written bg-card text-card-foreground is correct in every theme that will ever exist, because it asked for a role and let the theme answer.
Here is a real registry block rendered live. Every color in it, background, text, borders, the accent, is a semantic token. That is the entire reason it can drop into your app and look like it belongs.
Adding your own token, end to end
Say your product needs a "success" color the base theme does not ship. The token pattern makes this a three-step move, and the third step is the one people forget.
/* 1. Raw token, both modes */
:root {
--success: oklch(0.59 0.13 163);
--success-foreground: oklch(0.985 0 0);
}
.dark {
--success: oklch(0.69 0.15 163);
--success-foreground: oklch(0.205 0 0);
}
/* 2. Expose it to Tailwind so utilities get generated */
@theme inline {
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
}// 3. Use the role, never the raw value
<span className="rounded-md bg-success px-2 py-0.5 text-success-foreground">
Deployed
</span>Skip step two and bg-success silently does nothing, because no utility was ever minted. That single omission is the most common "why isn't my custom color working" bug in Tailwind v4 themes, and now you know exactly where it lives.
One variable, the whole radius scale
Color is the visible part of a theme, but the same token discipline runs through the geometry. There is a single --radius: 0.625rem at the root, and the entire rounded scale is derived from it with calc():
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}rounded-sm, rounded-lg, and rounded-2xl are not independent values you keep in sync by hand. They are offsets from one anchor. Set --radius: 0 and the whole product turns sharp and architectural; set it to 1rem and everything softens together, in proportion, in one edit. You watched this happen on the radius slider above: a single number rewriting every corner in the preview at once.
Fonts and shadows ride the same rails. The @theme inline block maps --font-sans to whatever font variable your app loaded, and the shadow scale to a set of --shadow-* tokens, so typography and elevation are theme-swappable by the same mechanism, not special cases.
Dark mode without the flash
The token architecture makes dark mode almost trivial to define, as we saw: it is one extra block of values. The hard part is the other half, applying the right theme before the first paint so users never see a flash of the wrong one.
That flash, the FOUC, happens because the server cannot know a returning user's chosen theme. It renders the default (usually light), ships the HTML, and only after JavaScript hydrates does the app discover the real preference and switch. For a beat, the page is the wrong color.
The fix is a tiny script that runs before the browser paints, reads the saved preference, and sets the class on <html> synchronously. You do not have to write it. next-themes injects exactly that script, and shadcn's Next.js dark mode guide wires it up in a few lines:
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}Every prop earns its place:
attribute="class"tells next-themes to toggle theclasson<html>, which is precisely what our@custom-variant dark (&:is(.dark *))is listening for.suppressHydrationWarningis required, and only on<html>. The pre-paint script mutates that element before React hydrates, so the server and client markup deliberately disagree for one instant. This prop tells React not to warn about that single, intentional mismatch.enableSystemanddefaultTheme="system"respect the OS preference until the user overrides it.disableTransitionOnChangekills CSS transitions during the switch, so toggling does not produce a half-second smear of every color animating at once.
The elegant part is what you do not have to do. Because the theme is just CSS variables under a class, the toggle is a class flip on one element. Server Components never re-render. There is no theme value threaded through React context into every colored element, no useTheme() in your buttons. A Server Component written with bg-card text-card-foreground re-themes for free, on the server's initial HTML, with zero client JavaScript of its own. The only component that needs "use client" is the toggle itself.
The one class you must not hardcode
The whole system rests on components asking for roles. The instant a component hardcodes text-white or bg-slate-950 "because it is a dark section anyway," it stops obeying the theme and will break in the next one. If a surface is genuinely always dark regardless of mode, that is a real case, but make it a deliberate token (a fixed panel color), not an accident. A theme is only as reliable as its least disciplined component.
More than two modes
Nothing about this caps you at light and dark. This site ships a third theme, a warm "paper" mode, using the identical pattern: a class, a block of token overrides, and one custom variant.
@custom-variant paper (&:is(.paper *));
.paper {
--background: oklch(0.97 0.01 85);
--foreground: oklch(0.25 0.02 50);
--card: oklch(0.95 0.015 80);
--primary: oklch(0.45 0.08 45);
--border: oklch(0.85 0.03 70);
/* ...the rest of the same token names... */
}Look at those values against the neutral theme's. The paper mode is not grayscale, its neutrals carry a little chroma (0.01 to 0.03) at a warm hue near 50 to 85. That is the OKLCH tinted-neutral trick from earlier, applied to build an entire cream-and-sepia surface set without a single component change. To expose it, you list your themes on the provider and let users pick:
<ThemeProvider attribute="class" themes={["light", "dark", "paper"]}>Multi-brand products live here too. A white-label app can carry one token block per client, keyed off a data-theme attribute instead of a class, and swap an entire visual identity by changing one attribute on <html>. Same machine, more presets.
Shipping the whole thing in one install
Here is where owning your tokens pays its largest dividend. A theme defined this cleanly, pure CSS variables in named layers, is a portable artifact. shadcn's registry can distribute it, and as of the 2026 CLI it can distribute the entire design system, not just components.
A theme is a registry item with type: "registry:theme". Its cssVars object mirrors the exact structure you now understand, a shared theme bucket plus light and dark overrides:
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "my-brand",
"type": "registry:theme",
"cssVars": {
"theme": {
"font-sans": "Inter, sans-serif",
"radius": "0.5rem"
},
"light": {
"primary": "oklch(0.55 0.20 255)",
"primary-foreground": "oklch(0.985 0 0)"
},
"dark": {
"primary": "oklch(0.62 0.19 255)",
"primary-foreground": "oklch(0.205 0 0)"
}
}
}The registry can also inject raw CSS through a css property (custom @layer rules, @utility definitions), so animations and one-off utilities travel with the theme instead of being copy-paste instructions in a README.
Step up one more level and you reach type: "registry:base", meant for an entire design system in a single payload: components, their dependencies, the CSS variables, the fonts, and the config, all installed at once. The consumer runs one command against your URL:
npx shadcn@latest add https://your-site.com/r/my-brand.jsonand their globals.css gains your token blocks, their config gains your fonts, and their project speaks your design language from the next render. No package to version, no upgrade treadmill, no lock-in. Just source, in their repo, theirs to edit, which is the entire point of this model.
If you would rather design the token values in a UI than hand-tune OKLCH channels, tools like the excellent tweakcn give you a visual editor that emits exactly this :root / .dark / @theme inline structure, ready to paste or publish as a registry item.
The one-sentence version
A shadcn theme is not a color scheme, it is a small graph of role-named tokens: raw OKLCH values in :root and .dark, promoted to utilities through @theme inline, consumed by components that only ever ask for roles, and portable enough to install with a single command. Master the graph and the colors become the easy part, one hue slider, one radius, one class on <html>, and a whole product moves in step.
Every block in our catalog is built on exactly these tokens, which is why any of them drops into your theme, light, dark, paper, or your own, and looks like you made it.