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

Published: 2026-08-12 · Author: Matt Wierzbicki · Canonical: https://www.shadcndesign.com/blog/how-to-theme-shadcn-ui-with-css-variables

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](https://ui.shadcn.com/docs/theming). 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 <Link href="/theme-generator">Theme Generator</Link> 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 <Link href="/blog/how-semantic-colors-work-in-shadcn-ui">how semantic colors work in shadcn/ui</Link>, 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](https://ui.shadcn.com/docs/theming) 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`:

```css
: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:

```css
: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

```css
: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:

```css
@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:

```css
: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:

```css
@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`:

```css
: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 <Link href="/docs/styles">styles docs</Link> 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 <Link href="/blog/how-semantic-colors-work-in-shadcn-ui">semantic colors post</Link> 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 <Link href="/theme-generator">shadcn Theme Generator</Link> 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 <Link href="/blog/how-to-paste-tailwind-v4-theme-css-variables-from-shadcn-theme-generator">how to paste Tailwind v4 theme CSS variables</Link>, 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.
