A practical guide to theming shadcn/ui with CSS variables: OKLCH tokens, dark mode, custom brand palettes, and applying a full theme in minutes.

Theming shadcn/ui comes down to one file: globals.css. You change CSS variables in :root (light mode) and .dark (dark mode), and every component updates at once. No config files to touch, no component code to edit.
Since the Tailwind v4 update, shadcn/ui defines colors in OKLCH and exposes them to Tailwind through an @theme inline block, per the official theming docs. So the whole job is: pick your values, put them in the right variables, done.
I'll show you the exact variables that matter, then build a custom brand theme step by step with real values you can paste and run.
TL;DR: shadcn/ui components read semantic CSS variables like
--primary,--background, and--border. Define light values in:root, dark values in.dark, and map them to Tailwind utilities with@theme inline. Change the variables, and the whole UI follows. If you'd rather skip the manual work, the Theme Generator outputs the full block for you.
Every shadcn/ui component is styled with utility classes like bg-primary or text-muted-foreground. Those utilities don't point at fixed colors. They point at CSS variables.
The chain looks like this:
--primary: oklch(0.205 0 0); in :root.@theme inline block tells Tailwind v4 about it: --color-primary: var(--primary);.bg-primary utility from that token.bg-primary, so it renders your color.That's the entire system. You never restyle components. You retarget the variables they already read.
The naming follows a semantic convention — colors are named by role (primary, muted, destructive), not by hue. I've written a full explainer on why that convention exists and how the pairs work in how semantic colors work in shadcn/ui, so I won't repeat it here. Short version: every surface color has a matching -foreground for the text on top of it.
One prerequisite: your components.json needs "cssVariables": true under tailwind (this is the default). With false, components ship hard-coded utility classes instead, and none of this applies.
The theming docs list every token, so I'll group the ones you'll touch most instead of copying the full list.
Base surfaces and text:
--background / --foreground — the page canvas and default text--card / --card-foreground — cards and panels--popover / --popover-foreground — dropdowns, tooltips, popoversActions and emphasis:
--primary / --primary-foreground — your brand color; buttons, active states--secondary / --secondary-foreground — lower-emphasis actions--accent / --accent-foreground — hover states, subtle highlights--muted / --muted-foreground — de-emphasized surfaces and helper text--destructive — errors and dangerous actionsLines and focus:
--border — dividers and outlines--input — form field borders--ring — focus ringsThe rest: --chart-1 through --chart-5 for data viz, a --sidebar family that mirrors the main tokens for sidebar layouts, and --radius for corner rounding.
In practice, --primary, --background, --muted, --border, and --radius do most of the visible work. Get those five right and the theme already feels intentional.
Let's theme a product around a violet brand color. All values are OKLCH — the format shadcn/ui switched to with Tailwind v4. The syntax is oklch(lightness chroma hue): lightness from 0 to 1, chroma is saturation, hue is an angle in degrees.
In your globals.css, inside :root:
:root {
--primary: oklch(0.54 0.22 293);
--primary-foreground: oklch(0.985 0 0);
}
That's a saturated violet with near-white text on top. Check the contrast of this pair first — it's the most-seen combination in your UI.
Pure gray neutrals next to a strong brand color can feel disconnected. A tiny amount of chroma in the same hue ties them together:
:root {
--background: oklch(0.995 0.002 293);
--foreground: oklch(0.15 0.01 293);
--muted: oklch(0.96 0.008 293);
--muted-foreground: oklch(0.5 0.02 293);
--border: oklch(0.91 0.01 293);
--input: oklch(0.91 0.01 293);
--ring: oklch(0.54 0.22 293);
}
Notice the chroma values: 0.002 to 0.02. That's the point. The hue matches the brand (293), but the saturation is so low the surfaces still read as neutral. This is the difference between "themed" and "everything is purple."
Setting --ring to the primary color makes focus states feel branded instead of default-blue.
:root {
--secondary: oklch(0.96 0.015 293);
--secondary-foreground: oklch(0.3 0.05 293);
--accent: oklch(0.95 0.02 293);
--accent-foreground: oklch(0.3 0.05 293);
--card: oklch(1 0 0);
--card-foreground: oklch(0.15 0.01 293);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.15 0.01 293);
--destructive: oklch(0.577 0.245 27.325);
}
I kept --destructive at the shadcn/ui default red — error colors are a convention users already know, and there's rarely a reason to brand them.
If you installed shadcn/ui normally, your globals.css already has this block. It's what turns your variables into Tailwind utilities:
@theme inline {
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-background: var(--background);
--color-foreground: var(--foreground);
/* ...one line per token */
}
You only edit this block when you add a new token. For example, a warning color the default set doesn't have:
:root {
--warning: oklch(0.84 0.16 84);
--warning-foreground: oklch(0.28 0.07 46);
}
@theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}
Now bg-warning and text-warning-foreground work everywhere, exactly like the built-in tokens. This pattern comes straight from the official docs.
Dark mode is the same variables redefined inside a .dark selector. shadcn/ui uses a custom variant so anything under a .dark class ancestor picks up the overrides:
@custom-variant dark (&:is(.dark *));
.dark {
--background: oklch(0.13 0.01 293);
--foreground: oklch(0.98 0.005 293);
--primary: oklch(0.7 0.18 293);
--primary-foreground: oklch(0.15 0.03 293);
--muted: oklch(0.22 0.015 293);
--muted-foreground: oklch(0.7 0.02 293);
--border: oklch(0.26 0.015 293);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.7 0.18 293);
}
Two things I always adjust for dark mode:
oklch(1 0 0 / 15%) for --input is a trick from the default theme itself — it adapts to whatever surface sits behind it.Your app still needs to toggle the .dark class on the html element — the CSS only defines what dark mode looks like, not when it applies.
Corner rounding runs through one base token. The docs define the scale as multiples of --radius:
:root {
--radius: 0.625rem;
}
@theme inline {
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
}
Set --radius: 0rem and everything goes sharp. Set 1rem and everything softens — buttons, cards, inputs, all in proportion. It's the single highest-leverage token for changing how a theme feels.
Fonts follow the same pattern. Map your font variables in @theme inline (--font-sans: var(--font-geist-sans);) and Tailwind's font-sans utility uses them. If you're deciding between the visual styles shadcn/ui ships with before you customize further, the styles docs cover how they differ.
Editing component classes instead of tokens. If you find yourself changing bg-primary to bg-violet-600 inside button.tsx, stop. You've forked the component from the theme system, and dark mode, future theme changes, and consistency all break quietly. Change --primary instead.
Mixing color formats. Older shadcn/ui setups (pre-Tailwind v4) stored raw HSL channel values and wrapped them with hsl() in the config. If you paste those old-style values into a v4 OKLCH setup — or paste oklch(...) values into a project still wrapping variables in hsl() — colors silently fail. Pick one format per project and migrate fully.
Defining a variable but not mapping it. A new token in :root does nothing until @theme inline has a matching --color-* line. If bg-warning isn't generating, this is almost always why.
Forgetting the .dark overrides for new tokens. Add --warning only to :root and dark mode will show the light-mode yellow. Every custom token needs both definitions.
Styling with the wrong role. Using --accent as a second brand color, or --muted-foreground for body text, works until it doesn't. Roles have jobs — the semantic colors post covers what each one is for.
Everything above is worth understanding, but you don't have to hand-tune 30 variables per mode. The shadcn Theme Generator takes one brand color and produces the complete block — :root, .dark, OKLCH values, radius — following the same conventions I walked through here.
Once you have the output, pasting it correctly has its own gotchas (block placement, keeping @theme inline intact, restarting the dev server). I covered that workflow in detail in how to paste Tailwind v4 theme CSS variables, so follow that guide for the paste step.
My honest recommendation: generate first, then hand-edit the two or three tokens that don't feel right. That's faster than building from scratch and teaches you the same lessons.
No. With Tailwind v4, shadcn/ui theming is CSS-first. Colors, radius, and fonts are all defined in globals.css via CSS variables and @theme inline. There's no theme section in a config file to maintain.
CSS variables accept any valid color value, so technically yes. But the current shadcn/ui defaults use OKLCH, and OKLCH gives you perceptually consistent lightness — two colors with the same lightness value actually look equally bright, which makes building palettes much more predictable. I'd convert to OKLCH rather than fight the convention.
Two usual causes: the token is defined in :root but missing from @theme inline (add a --color-yourtoken: var(--yourtoken); line), or the dev server hasn't picked up the CSS change (restart it). Check those in that order.
Theming shadcn/ui is a small, learnable system: semantic variables in :root and .dark, a mapping block for Tailwind, OKLCH values, one radius token. Once you've built one theme by hand, you'll be able to read any shadcn/ui theme — generated or not — and know exactly which line to change.
Start with --primary, tint your neutrals, fix dark mode's lightness, and leave the rest alone until something bothers you. That restraint is what keeps a theme maintainable.
Founder @ shadcndesign.com

How shadcn/ui charts actually work: what ChartContainer does, how ChartConfig drives colors, and what breaks when you move from Recharts 2 to Recharts 3.

How shadcn/ui registries actually work: registry.json, URL resolution, styles, and namespaces — explained by a team that runs a production registry.

The Default style in shadcn/ui is deprecated and New York is what new projects get. Here's what actually differed between the two, what New York looks like today, and what to do if your components.json still says default.