React

How to theme shadcn/ui with CSS variables

Change the tokens in :root and .dark, expose them to Tailwind with @theme inline, and measure contrast. The component classes stay untouched.

Key points

  • A shadcn theme is a set of semantic tokens in `:root` and `.dark`, not a set of new classes.
  • Every token must be mapped in `@theme inline`, otherwise Tailwind never generates the matching utility.
  • Measure contrast in both themes: 4.5:1 for normal text, 3:1 for large text.

Short answer: change the token values in :root and .dark in the global CSS, expose them to Tailwind with @theme inline, and leave the component classes alone. A theme is the map of roles, not a second copy of the components.

Where the tokens live

The CLI reads components.json: tailwind.css points at the global file, tailwind.cssVariables says whether components use tokens or inline utilities. With true, the default, every class speaks in roles.

{
  "tailwind": {
    "config": "",
    "css": "app/globals.css",
    "baseColor": "neutral",
    "cssVariables": true
  }
}

The reason it works is simple: bg-primary holds no colour, it holds var(--primary). Change the variable and every component that reads it changes too, without opening a file in components/ui.

The tokens that matter most

TokenWhat it controls
background / foregroundThe default page background and text.
card / card-foregroundRaised surfaces and the content on top of them.
primary / primary-foregroundHigh-emphasis actions, selected states, active badges.
muted / muted-foregroundSecondary text, placeholders, empty states.
border / input / ringBorders, form controls and the focus ring.
radiusThe base radius the whole scale derives from.

The minimum theme, in CSS

@import "tailwindcss";
@import "shadcn/tailwind.css";

@custom-variant dark (&:is(.dark *));

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.55 0.18 255);
  --primary-foreground: oklch(0.985 0 0);
  --radius: 0.625rem;
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --primary: oklch(0.72 0.14 255);
  --primary-foreground: oklch(0.205 0 0);
}

The variables alone are not enough: Tailwind has to know that --primary is a colour usable in utilities. The mapping sits in @theme inline, and without it bg-primary is never generated.

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --radius-lg: var(--radius);
}

Source: the shadcn/ui Theming page (MIT licence) documents the same tokens and the same mapping; the contrast threshold comes from WCAG 2.2 AA, not from an aesthetic preference.

Adding a new token

A state colour, a warning for instance, takes three steps: light value in :root, dark value in .dark, mapping in @theme inline. Skip the third and the variable exists in the DOM while no class reads it.

:root {
  --warning: oklch(0.84 0.16 84);
  --warning-foreground: oklch(0.28 0.07 46);
}

.dark {
  --warning: oklch(0.41 0.11 46);
  --warning-foreground: oklch(0.99 0.02 95);
}

@theme inline {
  --color-warning: var(--warning);
  --color-warning-foreground: var(--warning-foreground);
}

Contrast: the one check you cannot postpone

Measure every text-on-background pair in both themes: 4.5:1 for normal text, 3:1 for large text, as WCAG 2.2 AA requires. The first token to fail is always muted-foreground, because it starts muted by design. The method is described in UI palettes and measured contrast.

A named limitation

Tokens govern only what components read. A copied block with classes such as bg-zinc-950 sits outside the theme, and cssVariables is an install-time choice: to change it, the official docs ask you to delete and reinstall the components.

Where to continue

In the EneUI catalogue you find the shadcn library, the chart component record and a chart block with preview. For dark mode read dark mode without a second design system and dark mode without a flash; for token maths, Tailwind calc with CSS variables. The comparison of ready-made themes is free versus premium shadcn themes.

Frequently asked questions

Do I edit the files in components/ui to change the theme?

No. When components use semantic tokens, you change the values in :root and .dark and the classes stay as they are: bg-primary always reads var(--primary), whatever the colour.

Why does bg-primary not change after the edit?

Almost always because the @theme inline mapping is missing, or because the CSS you edited is not the file named in components.json. Check both before touching the components.

How do I add a state colour, such as a warning?

Define --warning and --warning-foreground in :root and .dark, then map --color-warning and --color-warning-foreground in @theme inline. Without the mapping, bg-warning does not exist.

Does dark mode need JavaScript?

The CSS does not: the .dark class on an ancestor is enough. JavaScript is only needed to toggle the class, and an inline script in the head prevents the wrong theme flashing on first load.

Can I use hex values instead of oklch?

Yes. CSS variables accept any valid colour syntax; oklch is the default in recent shadcn themes, not a requirement. You still have to measure contrast.