How to Manage shadcn/ui Dark Mode + Multi-Brand Themes with a Single CSS Variable
Ten dark mode bug tickets stacking up, with 200 components to fix? I've been in exactly that situation. Once you start attaching dark: classes component by component, you enter a maintenance hell where changing a single color means opening dozens of files.
That experience made me change course. By using CSS custom properties (CSS variables) as design tokens, you can support dark mode and multi-brand theming simultaneously without touching a single line of component code. shadcn/ui is designed exactly this way, and Tailwind v4's @theme inline makes it even cleaner.
This article is aimed at developers using Next.js + Tailwind. It's fine if you're just starting with shadcn/ui. By the end, you'll be able to build a structure where adding a new brand theme requires nothing more than a few lines of CSS.
Environment for this article Next.js 14+, shadcn/ui (Tailwind v4 based), next-themes
When this approach doesn't fit: If your project must support older browsers (below Chrome 111, Safari 15.4), you'll need the additional work of declaring HSL fallbacks alongside the OKLCH values used in shadcn/ui's default tokens.
Core Concepts
Two Token Layers: Primitive and Semantic
Distinguishing these two concepts is the key to this entire system. I personally experienced naming conventions collapsing when tokens multiplied into dozens — because I had a fuzzy understanding of them at first.
| Layer | Role | Example |
|---|---|---|
| Primitive tokens | The actual color values themselves | oklch(0.55 0.22 265) |
| Semantic tokens | Assign roles to Primitives | --primary, --background, --foreground |
shadcn/ui components reference only Semantic tokens. Components don't care what actual color value --primary points to. That's why you don't need to touch components when switching themes.
It's also worth keeping these two layers clearly separated in your code. When they're mixed in one block, it becomes hard to track which references point where later on.
:root {
/* Primitive — actual color values */
--color-slate-950: oklch(0.145 0 0);
--color-white: oklch(1 0 0);
/* Semantic — mapping Primitives to roles */
--background: var(--color-white);
--foreground: var(--color-slate-950);
--primary: var(--color-slate-950);
--primary-foreground: var(--color-white);
--card: var(--color-white);
--card-foreground: var(--color-slate-950);
--border: oklch(0.922 0 0);
--radius: 0.625rem;
}
/* Dark mode: same Semantic token names, different values */
.dark {
--background: var(--color-slate-950);
--foreground: var(--color-white);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--border: oklch(0.269 0 0);
}A single .dark class propagates dark mode to every component.
What is the CSS Cascade? It's a CSS rule where redefining the same variable name in a parent selector overwrites it for all descendant elements. When you redefine
--backgroundfrom:rootinside a.darkblock, the new value automatically propagates to all elements beneath the element with the.darkclass.
Selector conflict warning: When mixing .dark and html[data-theme], selector specificity can collide in unintended ways. :root and html selectors have the same specificity, so declaration order matters. Keeping the order @theme inline → :root → .dark → html[data-theme] ensures the Cascade behaves as intended.
OKLCH Color Space: Why Switch from HSL?
Honestly, my first thought was "can't I just use HSL?" But when you start fine-tuning dark mode, HSL's weaknesses appear. Even at the same Saturation value, perceived brightness varies by Hue — so to match the lightness of yellow and blue, I had to manually adjust values one by one.
/* HSL: same saturation (100%) but perceived brightness varies by hue */
hsl(60 100% 50%) /* Yellow — blindingly bright */
hsl(240 100% 50%) /* Blue — looks relatively dark */
/* OKLCH: the L value matches actual perceived brightness */
oklch(0.96 0.21 109) /* Yellow range */
oklch(0.45 0.31 264) /* Blue range — just looking at L makes the brightness difference clear */OKLCH is a color space calibrated to human vision, so the same L (lightness) value actually looks like a similar brightness. This is why color chroma stays consistent in dark mode.
OKLCH takes the form
oklch(lightness chroma hue). Lightness ranges from 0 (black) to 1 (white), chroma from 0 to ~0.4, and hue from 0 to 360 degrees. Supported in Chrome 111+ and Safari 15.4+.
Tailwind v4's @theme inline: The End of Config Files
Before Tailwind v4, you defined color palettes in tailwind.config.js. You had to keep two places in sync: CSS variables and the Tailwind config. Tailwind v4 consolidates this into CSS.
It's important to understand the difference between @theme and @theme inline here. I once used this without knowing and ended up in the bizarre situation of having two sets of CSS variables generated.
@theme: Tailwind directly creates new CSS variables and binds them to utility classes.@theme inline: References already-declared CSS variables. It does not create duplicate variables.
If you've already declared variables like --primary in :root, using @theme will create another variable called --color-primary, causing confusion about which takes precedence. @theme inline prevents this duplication.
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--radius-lg: var(--radius);
}Now Tailwind utility classes like bg-primary, text-foreground, border-border, and rounded-lg automatically reflect the CSS variables. tailwind.config.js is no longer needed.
Practical Application
Time to connect the concepts to code. Four examples flow together as parts of a single project.
Example 1: Full globals.css Structure — Centralizing Tokens
If you assemble fragmented snippets out of order, the Cascade won't behave as intended. The fastest approach is to see the complete file structure all at once.
/* globals.css */
@import "tailwindcss";
/* 1. Tailwind v4 theme binding — must come first */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--radius-lg: var(--radius);
}
/* 2. Default light theme */
:root {
/* Primitive */
--color-slate-950: oklch(0.145 0 0);
--color-white: oklch(1 0 0);
/* Semantic → Primitive references */
--background: var(--color-white);
--foreground: var(--color-slate-950);
--primary: var(--color-slate-950);
--primary-foreground: var(--color-white);
--card: var(--color-white);
--card-foreground: var(--color-slate-950);
--border: oklch(0.922 0 0);
--radius: 0.625rem;
}
/* 3. Default dark theme */
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--card: oklch(0.205 0 0); /* Should be 3–5% brighter than background to create depth */
--card-foreground: oklch(0.985 0 0);
--border: oklch(0.269 0 0);
}
/* 4. Brand themes — add below as needed */
html[data-theme='brand-a'] {
--background: oklch(0.98 0.005 280);
--foreground: oklch(0.15 0.01 280);
--primary: oklch(0.55 0.22 285);
--primary-foreground: oklch(1 0 0);
--card: oklch(0.96 0.008 280);
--border: oklch(0.88 0.015 280);
}Keeping the order @theme inline → :root → .dark → html[data-theme] ensures the Cascade behaves as intended without selector conflicts.
Example 2: Wiring Up Dark Mode Toggling with next-themes
With CSS variables defined, it's time to wire up the actual switching in Next.js. next-themes's ThemeProvider toggles the .dark class on the html element, and the CSS Cascade handles the rest.
// app/providers.tsx
import { ThemeProvider } from 'next-themes'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
)
}| Setting | Meaning |
|---|---|
attribute="class" |
Toggles the class in the form html.dark |
defaultTheme="system" |
Uses the OS dark mode setting as the default |
enableSystem |
Enables detection of the prefers-color-scheme media query |
disableTransitionOnChange |
Prevents flickering during theme transitions |
With the CSS variables connected, it's time to build the toggle button. There's one easy mistake to make here: rendering the theme value without a mounted check will cause server/client hydration mismatch warnings.
// components/theme-toggle.tsx
'use client'
import { useState, useEffect } from 'react'
import { useTheme } from 'next-themes'
export function ThemeToggle() {
const [mounted, setMounted] = useState(false)
const { theme, setTheme } = useTheme()
useEffect(() => setMounted(true), [])
// Must not render before mount to avoid hydration mismatch
if (!mounted) return null
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground"
>
{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
</button>
)
}Example 3: Multi-Brand Theme Configuration
With dark mode toggling connected, it's time to extend this structure for multiple brands. In SaaS products, the need to support per-customer brand colors comes up more often than you'd expect. The pattern of defining multiple token sets via the data-theme attribute has become the standard approach.
/* Brand theme section in globals.css */
/* Brand A — Purple range */
html[data-theme='brand-a'] {
--background: oklch(0.98 0.005 280);
--foreground: oklch(0.15 0.01 280);
--primary: oklch(0.55 0.22 285);
--primary-foreground: oklch(1 0 0);
--card: oklch(0.96 0.008 280);
--border: oklch(0.88 0.015 280);
}
/* Brand A Dark */
html[data-theme='brand-a-dark'] {
--background: oklch(0.15 0.01 285);
--foreground: oklch(0.95 0.005 280);
--primary: oklch(0.72 0.18 285);
--primary-foreground: oklch(0.1 0 0);
--card: oklch(0.2 0.015 285); /* Slightly brighter than background — brings out the layering */
--border: oklch(0.32 0.02 285);
}// app/providers.tsx — multi-theme version
import { ThemeProvider } from 'next-themes'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="data-theme"
themes={['light', 'dark', 'brand-a', 'brand-a-dark', 'brand-b', 'brand-b-dark']}
defaultTheme="light"
>
{children}
</ThemeProvider>
)
}Switching to attribute="data-theme" activates the html[data-theme='brand-a'] selector.
One limitation worth knowing upfront: A structure that separates brand-a and brand-a-dark as distinct theme names conflicts with the enableSystem option. Even when OS dark mode is active, it won't automatically switch to brand-a-dark. Supporting both system dark mode detection and brand themes simultaneously requires separate media query–based logic. It's worth aligning with your team on the structure before committing to it.
White-label refers to the practice of taking a single product and having multiple brands apply their own logo and colors to offer it as their own product. In a token-based system, you share components and simply swap the CSS variable set per customer.
Example 4: Writing Components That Only Use Tokens
Now to see how the tokens we defined are actually used — and this is also why the system works. The moment a hardcoded color like bg-white or text-gray-900 appears inside a component, dark mode support breaks in that file alone.
// ❌ Hardcoded — white background remains in dark mode
function Card({ children }: { children: React.ReactNode }) {
return (
<div className="bg-white text-gray-900 border border-gray-200 rounded-lg p-6">
{children}
</div>
)
}
// ✅ Token classes only — works correctly in any theme
function Card({ children }: { children: React.ReactNode }) {
return (
<div className="bg-card text-card-foreground border border-border rounded-lg p-6 shadow-sm">
{children}
</div>
)
}// Same principle for buttons
function PrimaryButton({ children, onClick }: {
children: React.ReactNode
onClick?: () => void
}) {
return (
<button
onClick={onClick}
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
>
{children}
</button>
)
}Writing a number after a slash like bg-primary/90 applies opacity to CSS variable–based colors. This syntax has been supported since Tailwind v3.1 and works more reliably in v4 with CSS variable–based colors.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Component immutability | Changing token values updates the entire UI. No component code modifications needed |
| Automatic dark mode propagation | A single .dark selector applies to all components |
| Multi-brand scalability | Adding one token set instantly supports white-label and tenant themes |
| Figma integration | Figma Variables can be managed with the same hierarchical structure as CSS variables |
| Accessibility validation unit | Contrast ratio can be verified in bulk at the semantic token level |
| Single source of truth | In Tailwind v4, the CSS file is the sole token source. No dual config file management needed |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| OKLCH browser support | Requires Chrome 111+ or Safari 15.4+ | Declare HSL fallbacks in parallel if older browsers are needed |
| Multi-brand + system dark mode | enableSystem auto-switching doesn't work with the brand-a-dark separation structure |
Requires separate media query–based switching logic |
| Token spec complexity | Management becomes harder as token count grows | Recommended to document naming convention guidelines within the team |
| CLI update conflicts | Custom modifications may be overwritten when updating components via the shadcn/ui CLI | Prefer wrapping original components with a wrapper component pattern |
| Card/popover layering | Depth can easily be lost in dark mode | Set --card and --popover 3–5% brighter than --background. Even a 3% increase in card background brings out the layering |
| Border visual noise | A too-bright --border can be overly prominent in dark mode |
Adjust to lower chroma relative to surrounding background, increasing lightness only slightly |
Most Common Mistakes in Practice
- Using hardcoded color classes like
bg-whiteortext-blackdirectly inside components — this causes dark mode to silently fail in those specific components. - Setting
--backgroundto pure blackoklch(0 0 0)— it's too intense for extended viewing. Setting lightness in the 4–10% range is easier on the eyes. - Using Tailwind v4 without
@theme inlineand wondering "why isn'tbg-primaryworking?" — the@theme inlineblock that connects CSS variables to Tailwind utility classes is mandatory. - Rendering the
themevalue inThemeTogglewithout amountedcheck — this causes server/client hydration mismatch warnings.
Closing Thoughts
Once you understand the two CSS variable layers (Primitive → Semantic) and @theme inline binding, you can build a system that supports dark mode and multi-brand theming simultaneously without modifying a single line of shadcn/ui component code.
Three steps you can start with right now:
- Install shadcn/ui with
npx shadcn@latest init, then trace the:rootand.darkblocks in the auto-generatedglobals.cssusing DevTools. - Enter your brand colors at tweakcn.com to generate OKLCH token values, then paste the exported CSS into
globals.css. - Set
ThemeProvidertoattribute="data-theme"and test brand theme switching yourself — that's when the value of this system becomes immediately tangible.
References
- shadcn/ui Theming Official Docs — The first doc to check for understanding the token variable list and base structure
- shadcn/ui Tailwind v4 Migration Guide — Use as a checklist for what changed when migrating from v3 to v4
- shadcn/ui Dark Mode Official Docs — Reference for next-themes integration settings
- shadcn/ui Official Changelog for Tailwind v4 Support (2025-02) — Summary of the background and changes behind the introduction of
@theme inline - Theming Shadcn with Tailwind v4 and CSS Variables — Medium — A more detailed English walkthrough of a similar flow to this article
- Building a Scalable Design System with Shadcn/UI, Tailwind CSS, and Design Tokens — Medium — For when you want to go deeper into token layer design
- Multi-Theme Magic: Next.js + shadcn/ui — Vaibhav Tyagi — A real-world implementation example of multi-theme patterns
- tweakcn — Interactive Theme Editor — A tool for visually adjusting OKLCH values and exporting tokens directly. Recommended as a starting point once you have a color direction
- Shadcn Studio — AI Theme Generator — For quickly drafting an initial direction with AI when you're not sure where to start with colors