Theming shadcn is not a styling task, it is a naming task. Every component in the ecosystem reads from the same small set of CSS variables, so the whole job is deciding what those variables hold and making sure nothing in your codebase reaches around them.
The token set
Eleven pairs do almost all the work, and they come in --x and --x-foreground pairs for a reason: the foreground token is the colour guaranteed to be readable on its partner.
--background and --foreground are the page. --card and --popover are surfaces that sit above it. --primary is the action colour, --secondary and --muted are the quieter ones, --accent is the hover and highlight state, and --destructive is the dangerous one. --border, --input and --ring are the lines and the focus indicator. Then the chart and sidebar sets, which most people never look at until a dashboard looks wrong.
The rule that keeps a theme coherent: never use a token's colour without its foreground partner. Text on --primary is --primary-foreground, always, and that is what makes a theme swappable rather than a set of coincidences.
Why OKLCH
Modern shadcn themes are written in OKLCH rather than HSL, and the reason is not novelty.
OKLCH is perceptually uniform: the lightness number means the same thing at every hue. In HSL, hsl(60 100% 50%) (yellow) and hsl(240 100% 50%) (blue) claim the same lightness and are wildly different to look at, which is why an HSL palette built by rotating hue produces a set where some colours look heavy and others look washed out.
In OKLCH you hold lightness and chroma, rotate the hue, and get a family that feels related. That is exactly what a token system needs.
Two practical notes. Chroma is not bounded the way saturation is: a high chroma value can fall outside the sRGB gamut, and browsers clip it, so two colours that look different in a picker can render identically. And oklch() needs a fallback only for genuinely old browsers now, which is usually a plain hex declaration before it in the same rule.
Tailwind v4 changes where this lives
In v3, theme configuration was JavaScript, and shadcn's variables were mapped into it through tailwind.config.js. In v4 the configuration moved into CSS with @theme, and the variables are declared alongside the rest of your stylesheet.
The practical consequence for anyone adopting a theme: a palette written for v3 needs translating rather than pasting, and a component from a registry that expects a config extension has to be adapted. It is the most common reason a copied theme "does not work".
Dark mode without the washed-out pass
Dark mode is not an inversion. Three things go wrong when it is treated as one.
Borders disappear. A border that reads clearly as a light grey on white becomes invisible as a dark grey on near-black. Dark themes usually need a border that is lighter relative to its background than the light theme's is.
Muted text fails contrast. --muted-foreground is the token used for every timestamp, caption and table header. Check it against --background in both modes, because passing in one says nothing about the other.
Pure black is a choice, and usually the wrong one. Near-black with a slight hue toward the accent reads as designed; #000 reads as absent, and on OLED it makes fast scrolling smear.
Also worth knowing: the dark set belongs on .dark, and your toggle must apply that class before the first paint, or every reader sees a flash of the light theme. That is a small inline script in the document head, not a React effect.
Keeping it from drifting
Nothing hardcodes a colour. A single bg-zinc-900 in a component means the next theme change misses that component, silently.
Extend the set rather than adding a parallel one. If the product needs a "success" colour, add --success and --success-foreground in the same shape. A one-off green in a component file is the first crack.
Check both modes in review. The generated half of a theme is where the failures live, and it is the half nobody screenshots.
Where else to look
The honest list, because the answer is not always us:
| Source | Best for | Trade-off |
|---|---|---|
| shadcn/ui theming docs | The canonical token list and the v4 setup | The base palettes are deliberately neutral |
| tweakcn | Editing tokens visually against real components | A tool, not a collection |
| Community themes | 623 free palettes with both modes to start from | Community-made, so check contrast yourself |
| oklch.com | Picking colours in the space itself, gamut warnings included | You are choosing every value |
Taking one
A theme is CSS variables, so applying one is replacing a block in your global stylesheet. The 21st MCP does the same from your editor: ask Claude, Cursor or Codex for a theme, get the palettes inline, and let the agent write the block.
Frequently asked
- Why do shadcn themes use OKLCH instead of HSL?
- Because OKLCH is perceptually uniform: the lightness number means the same thing at every hue. In HSL, yellow and blue at the same stated lightness look nothing alike, so a palette built by rotating hue comes out uneven. In OKLCH you hold lightness and chroma, rotate the hue, and get a family that feels related.
- What are the foreground tokens for?
- Each colour token has a foreground partner that is guaranteed readable on it. Text on primary is primary-foreground, always. Using a colour without its partner is what turns a theme into a set of coincidences that breaks the moment someone swaps the palette.
- Why does my theme not apply after pasting it?
- Two usual causes. Tailwind v4 moved theme configuration into CSS with @theme, so a palette written for v3's JavaScript config needs translating rather than pasting. And any deeper override already in your cascade wins over the variables you just set.
- Why does the page flash light before dark mode applies?
- Because the class is applied after hydration. The dark token set lives on .dark, and that class has to be on the document before the first paint, which means a small inline script in the head rather than a React effect. Everything else about dark mode is contrast: borders need to be lighter relative to their background, and muted foreground is where most themes fail.