shadcn color works through semantic CSS variable tokens — --primary, --background, --muted — not hardcoded hex values sitting inside component files. You change a palette by editing those tokens once in globals.css, and every component that reads them updates on the next paint. There is no theme provider, no runtime colour library, and nothing to rebuild.
Colour is the first thing most people change and the first thing most people break. This guide is part of our shadcn/ui theming hub, which covers the rest of the design-token surface: radius, typography, and dark mode.
Below: what each token in the default theme is for, how shadcn Tailwind v4 projects wire those tokens into utilities, three ways to build a custom palette, why the project switched to OKLCH, the mistakes that turn a brand colour into an unreadable button, and a checklist to run before you ship.
Version note: this guide reflects shadcn/ui with Tailwind CSS v4 as of September 2026. If your project is still on Tailwind v3, the token names are the same but the wiring is not — v3 maps tokens through tailwind.config.js rather than an @theme inline block, so compare the structure here against your own file before pasting anything.
Jump to: The token system · Token reference · Customizing colors · Why OKLCH · Dark mode and FOUC · Accessibility · Production checklist · FAQ
What is the shadcn color system?
The shadcn color system is a set of role-based shadcn theme variables — CSS custom properties like --primary and --muted — declared once in app/globals.css and read by every component through Tailwind utilities such as bg-primary. No component names a colour, so editing one token rethemes the whole app.
The names describe a role, not a colour: --destructive means "this action deletes something", not "red". Swap in a brand palette and a destructive button stays destructive without anyone renaming a variable.
That indirection is the entire point. Because no component names a colour, one edit in one file repaints the whole application, and a component you copied six months ago picks up today's brand without being touched.
In Tailwind v4 the wiring is a two-step map. Your raw tokens live in :root, and an @theme inline block exposes each one to Tailwind as a utility colour:
The --color-* names are what generate bg-primary, text-primary, and border-primary. You almost never edit that second block. You edit the first one. The inline keyword is what lets .dark override the value later — it resolves the variable at use site instead of copying it. The Tailwind v4 docs on @theme cover the rest of that namespace.
This model is a large part of why shadcn/ui passed 122,000 GitHub stars and why the shadcn CLI clears 8.7 million downloads a week as of August 2026 — the copy-paste model that displaced traditional component libraries only works if restyling copied code is trivial. Tokens are what make it trivial.
The three-layer token architecture
The default theme ships two layers. Serious design systems run three, and the distinction is worth understanding before your palette grows past one brand colour.
Brand tokens answer "what colour is our indigo?". Semantic tokens answer "what does this surface mean?". Component tokens answer "does this one widget need to disagree?". Out of the box you get layer two, and most projects paste raw OKLCH straight into it.
The separation decouples brand decisions from UI implementation. With one layer, a rebrand means auditing every token to work out which ones were meant to be your blue and which ones merely happened to be blue. With three, a rebrand edits the brand block and the semantic layer carries it everywhere. Figma kits built for shadcn/ui, including the Shadcn Studio kit, publish their variables in this shape for the same reason: it survives redesigns.
The cost is one extra indirection when debugging. For a single-brand marketing site that isn't worth paying. For anything multi-tenant, it is.
Which color tokens does shadcn/ui ship?
The default theme defines 18 core colour tokens, plus five chart colours and eight sidebar tokens. Values below are OKLCH, taken from the current default (Zinc) theme.
| Token | Light | Dark | Used for |
|---|---|---|---|
--background | oklch(1 0 0) | oklch(0.145 0 0) | Page surface |
--foreground | oklch(0.145 0 0) | oklch(0.985 0 0) | Default body text |
--card | oklch(1 0 0) | oklch(0.205 0 0) | Raised surfaces |
--primary | oklch(0.205 0 0) | oklch(0.922 0 0) | Brand actions, links |
--primary-foreground | oklch(0.985 0 0) | oklch(0.205 0 0) | Text on primary |
--secondary | oklch(0.97 0 0) | oklch(0.269 0 0) | Quiet buttons, badges |
--muted | oklch(0.97 0 0) | oklch(0.269 0 0) | Subdued backgrounds |
--muted-foreground | oklch(0.556 0 0) | oklch(0.708 0 0) | Secondary text |
--accent | oklch(0.97 0 0) | oklch(0.269 0 0) | Hover and active states |
--destructive | oklch(0.577 0.245 27.325) | oklch(0.704 0.191 22.216) | Delete actions, errors |
--destructive-foreground | not shipped | not shipped | Must create — see below |
--border | oklch(0.922 0 0) | oklch(1 0 0 / 10%) | Dividers, outlines |
--ring | oklch(0.708 0 0) | oklch(0.556 0 0) | Keyboard focus rings |
Rendered, the pairs look like this. Every swatch below sets only a background token and lets its paired foreground handle the label:
--background
page surface
--card
raised surface
--primary
brand actions
--secondary
quiet actions
--muted
subdued areas
--accent
hover states
--destructive
delete, errors
--popover
menus, tooltips
Every swatch draws its own text colour from the paired -foreground token, which is why the labels stay readable on all eight surfaces in both light and dark mode.
Why every token has a -foreground twin
A -foreground token exists to solve exactly one problem: readable text on top of its base. The pairing is a convention, not a computation. Nothing derives --primary-foreground from --primary at build time or at runtime, which means a brand colour that is lighter than the default will happily keep near-white text.
Dark foreground on a light amber primary
The default white foreground, left unchanged
Same --primary, two different --primary-foreground values. Raising the lightness of a brand colour without revisiting its foreground is the single most common way a custom palette breaks.
🎨 Want the pairing handled for you? The free shadcn theme generator derives each foreground from the base colour you pick and shows both modes side by side before you copy anything.
The one pair that isn't there: --destructive-foreground
Every surface token has a -foreground twin except one. The default theme doesn't ship --destructive-foreground at all — the destructive variant hardcodes text-white instead, which is fine as long as your destructive colour stays a mid-dark red.
The moment you change --destructive to something lighter, that hardcoded white has nothing to fall back on. Destructive buttons, alert variants and error badges all lose contrast at once, and because no token is involved there is nothing obvious to grep for. Declare the pair yourself:
⚠️ Declaring the token is only half of it. Point the components at it too — swap text-white for text-destructive-foreground in button.tsx, alert.tsx and badge.tsx. Re-running the CLI for those components restores the hardcoded value, so record the change wherever your team records upgrade steps.
The five chart tokens
--chart-1 through --chart-5 are a separate categorical ramp, deliberately kept out of the semantic set. They are not shades of your primary; they are five distinguishable hues meant to sit next to each other in a legend. Our walkthrough of 11 animated chart variants shows them in use, and every block in the live chart gallery reads them straight from whatever theme you have loaded.
Two rules keep a chart ramp usable. Don't reorder the tokens to suit one chart, because a series that changes colour between two pages is worse than a colour you dislike. And don't stop at five — if you need a twelfth series, the honest fix is a different chart, not --chart-12.
Using chart and sidebar tokens in practice
Charting libraries don't know about your theme, so you hand them the token. In Recharts — the library shadcn/ui charts wrap — stroke and fill take a CSS value, so var(--chart-1) works directly:
Key details:
- Pass
var(--chart-1), not"hsl(var(--chart-1))"— the wrapping function is a Tailwind v3 leftover that produces an invalid colour against v4 tokens, which Recharts renders as black. - Bind a series to a token by name, never by index. A
data.map()that assigns--chart-{i + 1}recolours every series the day someone reorders the array. - Tooltips and legends want
--popoverand--popover-foreground, not chart tokens.
Sidebar tokens are a separate eight-token set — --sidebar, --sidebar-foreground, --sidebar-primary, --sidebar-accent, --sidebar-border, --sidebar-ring and their foreground pairs. In the default theme they track the main neutrals closely enough that most people never notice them. Override them when the sidebar should read as a distinct surface — usually a dark rail inside an otherwise light app:
Test that in both modes. A rail hardcoded dark in :root and left undeclared in .dark is correct in light mode and invisible against a dark page — rail and content collapse into one flat surface. Our guide to building a resizable sidebar covers the layout side of the same component.
How do you customize shadcn colors?
There are three ways to define a shadcn custom color, in rough order of how much control they give you:
- Edit the tokens by hand in
app/globals.css— total control, no tooling. - Generate a full palette from a single brand colour, then paste the output.
- Pick a base colour at install time and let the CLI write the block for you.
All three produce the same artefact — a block of CSS variables in one file — so you can start with one and finish with another.
Method 1: Edit the tokens in globals.css
Open globals.css, find the :root and .dark blocks, and change values. No dependency, no tooling, complete control.
Note what happens to --primary across the two blocks: the hue and chroma stay put while lightness goes up in dark mode. A brand colour tuned for white backgrounds usually reads as a muddy smudge on a near-black one, and nudging lightness is the whole fix.
The payoff is visible the moment you swap one value. Nothing in the card below names a colour — the button, badge, inline link and focus ring all resolve through --primary:
Nothing in this card names a colour. The button, badge, link and focus ring all read --primary.
Key details:
- Tokens cascade, so setting them on a wrapper themes a subtree — useful for multi-tenant or white-label UI.
- Cast the style object to
React.CSSPropertiesin TypeScript; custom properties are not in the base type. - Editing
globals.csstriggers a normal hot reload. If colours look stale, it's a browser cache, not Tailwind.
Best for: teams with an existing brand colour who want exact values in every token.
Method 2: Generate a palette from one brand color
Balancing 18 tokens across two modes by hand is slow, and contrast is easy to lose on the fifth iteration. ShadcnDeck's browser-based theme editor takes a single brand colour, builds the full light and dark set around it, and previews the result on real components before you commit. It exports the same CSS variable block you'd have written by hand, so there is nothing to install and nothing to depend on later.
Best for: getting a production-ready palette in about a minute, with the foreground pairs already worked out.
Method 3: Pick a base color at install time
If the project doesn't exist yet, the cheapest option is to choose a neutral at init and adjust later. The CLI writes the whole token block for you:
The prompt offers Neutral, Gray, Zinc, Stone, and Slate. They differ only in how the greys lean — Zinc is faintly cool, Stone faintly warm — and that undertone shows up in every border and muted surface you ship, so it is worth ten seconds of thought. The CLI also reads external registries, so npx shadcn@latest add <registry-url> can install a published shadcn preset — a whole token block rather than a component — the same way it installs a button.
You'll also find community CLIs that print token blocks from named Tailwind hues. They work, but check the download counts before you wire one into a build — several of the ones ranking well in search sit in the low single digits per week and haven't shipped a release in months.
Best for: new projects, and anyone who wants a sane neutral base before applying brand colour.
Why did shadcn/ui move to OKLCH?
Because HSL lies about lightness. In HSL, hsl(60 100% 50%) (yellow) and hsl(240 100% 50%) (blue) claim identical lightness, and one of them is obviously brighter. Build a palette on that and every hue needs hand-tuning to look like it belongs to the same family.
OKLCH is perceptually uniform: the first value is lightness as an eye actually judges it, the second is chroma (saturation), the third is hue in degrees. Two colours with the same L read as equally bright regardless of hue, which is what makes a generated ramp hold together.
It also reaches colours sRGB hex cannot express, on displays that support them. Browser support has been broad since 2023, and since the values live in CSS variables you can fall back by simply writing HSL in the same token.
Reading the values takes a week or two to become intuitive, so keep a shadcn color picker that speaks the format to hand — oklch.com for a single value, our theme editor for a whole set. Convert your hex brand colour once and work in OKLCH from then on; round-tripping through hex quietly clips anything outside sRGB.
How do light and dark shadcn theme colors work?
There is no dark-mode logic in any component. There are two blocks of the same token names — :root and .dark — and a class on the html element that decides which one wins. That's the whole mechanism.
Preventing FOUC in SSR frameworks
This one catches every Next.js and Remix project once. The server has no idea what theme this visitor prefers — localStorage and prefers-color-scheme only exist in the browser. So the server renders light, the browser paints light, React hydrates, an effect reads the stored preference, and then the page flips to dark. That white flash is a FOUC — a flash of unstyled content — and on a slow connection it lasts long enough to be genuinely unpleasant.
The fix is a synchronous blocking script in <head>, ahead of any stylesheet or React bundle:
Key details:
- It must be blocking — no
async, nodefer, no external file. Anything deferred has already lost the race. - Put it before your stylesheet link. After it, you get a shorter flash instead of no flash.
- Wrap the
localStorageread in atryblock — in Safari private browsing it can throw and take the rest of the script with it. - In Next.js, inject it in the root layout with
<script dangerouslySetInnerHTML={{ __html: ... }} />, or letnext-themesdo it — that's most of what the package is for.
This is also why suppressHydrationWarning on <html> is not optional. The script mutates className before React hydrates, so server and client markup differ by design on that one attribute — and the prop tells React to ignore attribute differences on that single element, nothing more.
Which shadcn dark mode colors need a second look
Two tokens deserve more than a mechanical inversion. --border switches from a solid grey to oklch(1 0 0 / 10%), a translucent white, because a fixed dark border disappears against dark surfaces. And --card stops matching --background: in light mode both are white and elevation comes from the border, while in dark mode the card lifts slightly to read as raised. If you flatten that, your card layouts lose their sense of depth in dark mode without any obvious culprit.
How do you keep a custom palette accessible?
The bar is WCAG AA: 4.5:1 contrast for normal text against its background, 3:1 for large text and for the non-text parts of a control, like a focus ring. The default theme clears it. Your custom palette is your responsibility, and the CLI will not check it for you.
Three pairs cause nearly all real failures:
--primarywith--primary-foreground, because primary buttons carry the most user-facing text on most pages.--muted-foregroundon--background, the one people fail without noticing — it is designed to look faint, and "faint" slides past 4.5:1 easily.--destructivewith whatever sits on it, since a mid-lightness red plus white text is a classic near-miss at roughly 4:1.
A rough OKLCH heuristic: a lightness gap of about 0.4 between two tokens usually clears AA for body text. It is a starting point for the eye, not a substitute for a checker — chroma and hue both shift the real ratio. Paste the two values into the WebAIM contrast checker and read the real number. Ten seconds a pair, and only about six pairs are worth checking.
Don't forget --ring. Keyboard users navigate entirely by focus ring, and a ring tuned to blend politely into your brand is a ring nobody can see. It needs 3:1 against the surface behind it. The same care applies to every button variant you ship, since ghost and outline buttons lean hardest on border and ring contrast.
Four ways a custom palette goes wrong
Changing the base and forgetting the foreground. The single most common failure. Treat --primary and --primary-foreground as one edit with two lines.
Reaching for Tailwind's palette mid-component. A stray bg-blue-600 works, ships, and then sits there glowing in dark mode while everything around it adapts. If you need a colour the tokens don't cover, add a token.
Theming the light block only. Half a palette is worse than none: the app looks branded until a visitor's OS is set to dark, at which point they get your brand colour on the default dark neutrals, which were never chosen to sit together.
Overriding tokens inside component files. Editing components/ui/button.tsx to hardcode a colour breaks the contract that makes the system work — and it's exactly the change that gets silently reverted the next time someone re-runs the CLI for that component.
One honest caveat for 2026: semantic tokens are a genuinely tight constraint. A design that wants a gradient brand, six accent colours, or per-section palettes will fight this system, and the escape hatch — scoping token overrides on a wrapper, as in Method 1 — is a workaround rather than a first-class feature. If your brand needs that much colour, budget for the extra tokens up front instead of discovering them one component at a time.
🧩 Theming a page you haven't built yet? Our Next.js landing page walkthrough builds the layout first, so you have something real to test a palette against.
Which method should you use?
Choose manual editing when brand colours are already fixed by someone else and your job is to transcribe them exactly. You'll spend the time on contrast checking, not on colour picking.
Choose the generator when you have one colour and need the other seventeen tokens to agree with it. That is the case most projects are actually in, and hand-deriving a dark ramp is not a good use of an afternoon.
Choose a base colour at init when the project is new. Get Zinc or Stone in place, ship something, and apply brand colour once there are real screens to judge it on.
Whichever you pick, the token pair is the unit of work. Change --primary and --primary-foreground together, check the two blocks against each other, and the rest of the system holds. For the canonical reference, the official shadcn/ui theming docs list every token, and the shadcn/ui GitHub repository is where to read the actual component source when you need to know which token a variant reaches for.
If you'd rather apply a palette to a finished layout than build one from scratch, the ChatDeck SaaS landing page ships with this exact token structure wired through every section, and the wider shadcn/ui template collection follows the same convention.
Production checklist: is your shadcn theme ready to ship?
Every item here corresponds to a failure that has shipped to production somewhere. Ten minutes against this list is cheaper than any of them.
Tokens and pairs
- Every surface token has a matching
-foregroundvalue, including the--destructive-foregroundyou had to declare yourself. - The
.darkblock defines every token that differs from light mode — not just--backgroundand--foreground. - No hardcoded colour utilities (
bg-blue-600,text-white) survive insidecomponents/. Grep forbg-(red|blue|green|zinc|slate|gray)-and read every hit.
Contrast
- Every
-foregroundpair clears WCAG AA — 4.5:1 for body text, 3:1 for large — in both modes, not just the one you develop in. --ringhas at least 3:1 against the surface behind it. Tab through a form and confirm you can see where you are.--muted-foregroundon--backgroundwas measured, not eyeballed. It fails quietly.
Rendering
- FOUC is prevented by a blocking script in
<head>, verified by a hard reload on a throttled connection. suppressHydrationWarningsits on<html>and the console is clean.- In
components.json,tailwind.csspoints at the file your tokens actually live in.
Components in context
- Chart tokens are bound by name and in the same order on every page. A series that changes colour between screens reads as a bug in your data.
- Sidebar tokens have been checked in both modes, open and collapsed.
- The theme was tested on a real page — a form, a table, a chart, an empty state, a destructive dialog — not on an isolated button in a Storybook frame.
The last one carries most of the weight. Palettes fail at the seams: a disabled button on a muted card, an error message inside a popover, a badge on a chart tooltip. None of that shows up until real content sits in a real layout.
Related Posts
Keep going with the rest of the theming and component surface:
- shadcn charts guide - where the five chart tokens earn their keep
- shadcn card component guide - the surface tokens that carry elevation in dark mode
- shadcn button examples - every variant that depends on your primary pair
- shadcn components directory - guides to every primitive these tokens style
- the rise of shadcn/ui in 2026 - why owning the code changed how theming works
- build a landing page with Next.js and shadcn/ui - a layout to apply your new palette to
- free shadcn/ui templates - starting points that already follow the token convention





