Blog

How to theme shadcn/ui with CSS Variables (Tailwind v4 Guide)

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

article··8 min read
How to theme shadcn/ui with CSS Variables (Tailwind v4 Guide)

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.

How shadcn/ui theming works in one minute

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:

  1. You define a variable: --primary: oklch(0.205 0 0); in :root.
  2. An @theme inline block tells Tailwind v4 about it: --color-primary: var(--primary);.
  3. Tailwind generates the bg-primary utility from that token.
  4. The Button component uses 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 variables that actually matter

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, popovers

Actions 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 actions

Lines and focus:

  • --border — dividers and outlines
  • --input — form field borders
  • --ring — focus rings

The 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.

Step by step: build a custom brand theme

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.

Step 1: set the primary pair

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.

Step 2: tint the neutrals (subtly)

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.

Step 3: fill in the secondary roles

: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.

Step 4: confirm the @theme inline mapping exists

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: override, don't invert

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:

  • Lighten the primary. A saturated color that works on white often vibrates on near-black. Raising lightness (0.54 → 0.7) and easing chroma keeps it readable. The text on top flips to dark.
  • Borders get lighter than the background, not darker. In light mode, borders are darker than the surface. In dark mode they're lighter. A translucent white like 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.

Radius and typography

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.

Common mistakes

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.

The shortcut: generate, paste, ship

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.

FAQ

Do I need to touch tailwind.config for theming?

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.

Can I still use HSL or hex instead of OKLCH?

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.

Why isn't my custom color generating a Tailwind utility?

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.

Wrap-up

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.

MW
Matt Wierzbicki

Founder @ shadcndesign.com

Level-up your workflow today

One-time payment
14 days refund policy