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, three ways to build a custom palette, why the project switched to OKLCH, and the four mistakes that turn a brand colour into an unreadable button.
What is the shadcn color system?
shadcn/ui does not ship a fixed palette baked into components. It defines a small set of semantic tokens as CSS custom properties, and every component reads them through Tailwind classes like bg-primary or text-muted-foreground. The token names describe a role, not a colour: --destructive means "this action deletes something", not "red".
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.
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.
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 |
--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 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.
How do you customize shadcn colors?
Three approaches, in rough order of how much control they give you: edit the CSS by hand, generate a palette from one brand colour, or pick a base theme at install time. They produce the same artefact — a block of CSS variables — 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 pull a published theme's variables the same way it pulls a component.
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.
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.
suppressHydrationWarning is not optional. The theme script sets the class before React hydrates, so server and client markup differ by design on that one element, and React will warn about it otherwise.
Two tokens deserve a second look in dark mode. --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.
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 OKLCH colour picker is the fastest way to feel out lightness and chroma.
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.
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





