Customizing shadcn/ui: Colors, Radius, and Dark Mode Tokens
Stop fighting shadcn/ui's defaults. Here's how to actually own your color tokens, border radius, and dark mode variables without breaking the component contract.
shadcn/ui's Token System Is Not Optional, It's the Point
Honestly, most developers install shadcn/ui, run the CLI, and then spend the next three days fighting the default slate color palette. They tweak a Tailwind config here, override a class there — and end up with a mess that breaks every time they update a component. There's a better way.
shadcn/ui is built around CSS custom properties. The components don't import a theme file. They reference variables like --background, --foreground, --primary, and --radius directly. That's intentional. It means if you set those variables correctly, every component in your project updates at once.
The mistake people make is treating shadcn/ui like a normal component library with a theme object you pass in. It's not that. It's closer to a design token system with pre-built UI on top. Once that clicks, customization becomes straightforward instead of painful.
Where the Tokens Live and How Tailwind v4 Changed Things
In a fresh shadcn/ui install targeting Tailwind v4.0.2, your tokens land in globals.css inside two blocks: one for :root (light mode) and one for .dark (dark mode). The values are HSL channels — not full hsl() calls, just the raw numbers like 240 10% 3.9%. This is so Tailwind can compose alpha variants: hsl(var(--background) / 0.5) works without any extra setup.
With Tailwind v4's new features, the configuration model shifted. Instead of a tailwind.config.js with a colors object, you can now declare your design tokens directly in CSS using @theme. shadcn/ui's CLI already generates output that targets this new model, but if you're migrating an older project, you'll need to reconcile the old extend.colors approach with the new CSS-first one.
One thing that catches people: if you edit tailwind.config.js and also edit globals.css, you can end up with conflicting token definitions. Pick one source of truth. For shadcn/ui projects, the CSS file wins — keep your brand tokens there and don't replicate them in the config.
Replacing the Color Palette: A Practical Walkthrough
Say you want a teal-forward brand instead of the default slate. You don't need to touch any component files. You update the CSS variables in :root and .dark. Here's what a real token swap looks like, with OKLCH values for wider gamut support — a technique covered in detail in this guide to OKLCH colors in Tailwind:
:root {
--background: 0 0% 100%;
--foreground: 183 45% 10%;
--primary: 183 72% 35%;
--primary-foreground: 0 0% 100%;
--secondary: 183 30% 92%;
--secondary-foreground: 183 45% 10%;
--muted: 183 20% 95%;
--muted-foreground: 183 25% 45%;
--accent: 165 60% 40%;
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 183 20% 88%;
--input: 183 20% 88%;
--ring: 183 72% 35%;
--radius: 0.5rem;
}
.dark {
--background: 183 45% 5%;
--foreground: 183 15% 95%;
--primary: 183 72% 50%;
--primary-foreground: 183 45% 5%;
--secondary: 183 30% 15%;
--secondary-foreground: 183 15% 95%;
--muted: 183 25% 12%;
--muted-foreground: 183 20% 60%;
--accent: 165 60% 55%;
--accent-foreground: 183 45% 5%;
--destructive: 0 62% 40%;
--destructive-foreground: 0 0% 100%;
--border: 183 30% 18%;
--input: 183 30% 18%;
--ring: 183 72% 50%;
}That's the entire palette replacement. No component edits, no class overrides. Every Button, Badge, Input, and Card in your app now uses this palette. The ratio between --primary in light versus dark mode matters — if your light mode primary is at 35% lightness, your dark mode version should be around 50-55% to maintain contrast against the dark background.
Border Radius: One Variable to Rule All Components
The --radius variable is one of the most underused features of shadcn/ui's system. It drives border radius across all components, but it doesn't work as a single fixed value everywhere. Components use calc(var(--radius) - 2px) and calc(var(--radius) - 4px) for nested elements to maintain visual hierarchy.
Set --radius: 0rem and you get a sharp, technical aesthetic. Set --radius: 1rem and everything looks friendly and rounded. The sweet spot for most SaaS products sits around 0.5rem to 0.75rem. If you're building something that needs to look premium without being sterile, 0.625rem — exactly 10px at default font-size — tends to feel right.
One thing worth knowing: if you have custom components that aren't shadcn/ui primitives, you can reference --radius in those too. border-radius: var(--radius) in any CSS file picks it up. This is how you extend the system to your own components without maintaining two separate radius values across the codebase.
Dark Mode: The `.dark` Class Strategy vs Media Queries
shadcn/ui defaults to class-based dark mode — you toggle a .dark class on the <html> element. This is almost always what you want, because it gives users explicit control. But it requires you to actually implement the toggle. If you need a working theme toggle in React, that's a separate concern from the tokens themselves.
The question developers ask: what if I want prefers-color-scheme as a fallback when the user hasn't made a choice yet? You can layer both approaches. Use @media (prefers-color-scheme: dark) to set your dark tokens initially, then let the .dark class override it when someone explicitly chooses. The .dark block in your CSS needs to come after the media query block for specificity to work correctly.
One subtle issue: if you're using next-themes (the most common pairing with shadcn/ui), it handles this layering for you via the defaultTheme="system" prop. Don't fight it. Let next-themes manage the class, and keep your CSS focused purely on the token values.
Extending the Token Set for Custom Components
The built-in shadcn/ui tokens cover about 80% of what you need. The remaining 20% — things like sidebar backgrounds, chart colors, gradient endpoints, overlay colors — you add yourself. The naming convention matters here. Stay in the same flat namespace, use descriptive names, and document what each token is for.
/* In globals.css, add to both :root and .dark */
:root {
/* Sidebar */
--sidebar-background: 183 20% 97%;
--sidebar-border: 183 20% 88%;
/* Overlay at rgba(255,255,255,0.15) equivalent */
--overlay-light: 0 0% 100% / 0.15;
/* Chart palette */
--chart-1: 183 72% 35%;
--chart-2: 165 60% 40%;
--chart-3: 210 65% 50%;
--chart-4: 45 80% 55%;
--chart-5: 0 72% 51%;
}
/* Usage in a component */
export function Sidebar({ children }: { children: React.ReactNode }) {
return (
<aside
className="w-64 border-r"
style={{
background: 'hsl(var(--sidebar-background))',
borderColor: 'hsl(var(--sidebar-border))'
}}
>
{children}
</aside>
);
}Notice the sidebar example uses inline styles for the custom tokens instead of Tailwind utility classes. That's because Tailwind doesn't automatically generate utilities for variables you define yourself — unless you add them to the @theme block. For one-off custom tokens, inline styles are fine. For tokens you use frequently, add them to @theme so Tailwind generates the utilities.
Avoiding the Most Common Theming Mistakes
The hardest bug to track down is when you have a component that looks right in light mode but breaks in dark mode. Nine times out of ten, the culprit is a hardcoded color somewhere — a bg-white that should be bg-background, or a text-gray-900 that should be text-foreground. The shadcn/ui token system only works if you use the semantic utilities consistently.
Another common issue: conflicting specificity when combining shadcn/ui with other Tailwind component patterns. If you're mixing in Empire UI components or building on top of Tailwind component patterns, make sure your token overrides sit in :root and .dark at the top level — not scoped inside component selectors. Scoped CSS variable overrides can work, but they're confusing to debug.
What about glassmorphism effects on top of these tokens? Cards with rgba(255,255,255,0.15) backgrounds and backdrop blur need special handling in dark mode — the overlay color that works at that opacity in light mode usually looks wrong against a dark background. The advanced glassmorphism guide covers the dark-mode variant handling in detail. Short version: define separate --glass-background-light and --glass-background-dark tokens rather than trying to make one value work in both contexts.
Testing Your Token Overrides Before Shipping
Don't eyeball this. Build a token audit page — a simple route in your Next.js app that renders every shadcn/ui component variant side by side in both light and dark mode. It takes maybe two hours to set up and saves you from catching color contrast issues in production.
Run the color contrast checker against your --foreground on --background and --primary-foreground on --primary at minimum. WCAG AA requires a 4.5:1 ratio for normal text. The teal palette example above needs checking: a --primary of 183 72% 35% against white foreground works, but if you push the lightness up to 50% you start losing contrast on light mode buttons.
The final check before shipping: test with the browser's forced-colors mode. Go to DevTools, rendering tab, and enable forced colors. This simulates high-contrast accessibility modes. shadcn/ui components handle this reasonably well because they use semantic variables, but any custom tokens you add won't automatically map — you'll need explicit @media (forced-colors: active) overrides for those.
FAQ
Yes, as of Tailwind v4.0.2, OKLCH is supported natively. The catch is shadcn/ui components compose alpha variants using the HSL channel pattern — hsl(var(--primary) / 0.5). If you switch to OKLCH, you need to update the alpha composition syntax to oklch(var(--primary) / 0.5) everywhere it's used, which means touching a lot of component utilities. Most teams stick with HSL channels for now.
Scope the CSS variables to a wrapper element: .my-section { --primary: 210 65% 50%; }. All shadcn/ui components rendered inside that wrapper will pick up the override. This works for things like a landing page hero section that needs different brand colors than the main app. Watch out for portal-based components like Dialog and Tooltip — they render outside your wrapper in the DOM and won't inherit the scoped variables.
Color space handling. Safari has supported Display P3 wide gamut for years; Chrome's support improved more recently. If you're specifying colors that fall outside sRGB — certain bright teals and greens at high saturation — they'll render differently. Stick to saturation values below 80% in HSL to stay safely within sRGB, or use OKLCH with explicit display-p3 media queries to handle both.
No. All shadcn/ui components are built to use --radius via calc() variations, so they all update proportionally. The only things that can look off are components with inner nested elements — like a Card inside a Dialog — where the inner element's radius should optically be smaller than the outer. shadcn/ui handles this with calc(var(--radius) - 4px) for inner elements. At very small radius values like 0.25rem, that calc can produce negative numbers, which browsers clamp to 0. So fully sharp inner corners at small radius settings is expected behavior.
Add a new class to your token definitions: .high-contrast { --background: 0 0% 0%; --foreground: 0 0% 100%; ... }. Then apply that class to <html> the same way you'd apply .dark. next-themes supports multiple themes natively via the themes prop. The token file grows, but the component code doesn't change at all.
Yes — you can update CSS variables via JavaScript at runtime: document.documentElement.style.setProperty('--primary', '210 65% 50%'). Build a simple dev-only color picker panel that does exactly this. It lets you tune HSL values and see the result instantly across all components before you commit anything to the CSS file. Just remember to clear those inline styles before running your visual tests, or the inline declarations will override your CSS file values.