Tailwind v4 + shadcn/ui: Building a Themeable CMS
Design tokens, runtime theme switching, and a 15-variable contract
Tailwind v4 shipped a CSS-first config. shadcn/ui adopted it. Together they let you swap an entire CMS theme by changing seven CSS variables — no recompile, no theme JSON, no vendor lock-in. We rebuilt our admin around this and the result surprised us.
TL;DR: Tailwind v4 moved from tailwind.config.js to @theme blocks in CSS. shadcn/ui v2 follows the same model — design tokens live as CSS variables, not Tailwind classes baked at build time. For a CMS admin, this means you can ship 3 themes (or 30) without rebuilding, let users switch them at runtime, and stay one git pull behind upstream shadcn updates. We run 3 themes today (default blue, purple, soft-purple Unfold variant) on a single build. Total config: ~80 lines of CSS.
This post covers the actual mechanics — what changed in Tailwind v4, how shadcn/ui consumes those tokens, and the pattern we use to ship a themeable admin without forking shadcn. If you're building a CMS on shadcn/ui or evaluating the stack, this is the part most posts skip.
What Changed in Tailwind v4
Tailwind v4 is a rewrite. The headline change for theming: configuration moved from JavaScript into CSS.
In v3, you defined design tokens in tailwind.config.js:
// tailwind.config.js (v3)
module.exports = {
theme: {
extend: {
colors: {
primary: 'hsl(221 83% 53%)',
background: 'hsl(0 0% 100%)',
},
},
},
}
Those values got compiled into utility classes at build time. Want a second theme? You compiled twice, or you wired up CSS variables manually inside theme.extend.colors, or you swapped config files. None of those scaled past 1-2 themes.
In v4, the same tokens live in CSS:
/* app.css (v4) */
@import "tailwindcss";
@theme {
--color-primary: oklch(0.55 0.27 262);
--color-background: oklch(1 0 0);
}
That's the full config. No JavaScript. No content array (Tailwind v4 auto-detects). No theme.extend. The @theme block is a real CSS layer — you can override variables at any selector and Tailwind utility classes pick them up automatically.
Why this matters for theming
The key shift: utility classes now reference CSS variables, not baked values. bg-primary compiles to background-color: var(--color-primary). Override --color-primary inside [data-theme="purple"] and every bg-primary on the page flips colors instantly — no rebuild.
This is exactly the model shadcn/ui adopted in late 2024. When v4 stabilized, shadcn shipped an update that moved its own design tokens to @theme blocks. The two systems now share the same source of truth.
How shadcn/ui Consumes Tailwind v4 Tokens
shadcn/ui isn't a component library. It's a copy-paste collection of components that live in your repo, styled with Tailwind classes that reference CSS variables you define. That detail matters more in v4 than it did in v3.
A shadcn Button component looks like this:
// components/ui/button.tsx (shadcn)
const buttonVariants = cva(
"...",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground",
// ...
},
},
}
)
Notice: bg-primary, text-primary-foreground, bg-destructive. These are token references, not color values. The component never says "blue" or "#2563EB". It says "use whatever the current theme defines as primary."
The token contract
shadcn defines a fixed set of semantic tokens that every component consumes:
| Token | Purpose |
|---|---|
--background / --foreground |
Page background and main text |
--primary / --primary-foreground |
Brand color and text on top of it |
--secondary / --secondary-foreground |
Secondary buttons, less emphasis |
--muted / --muted-foreground |
Disabled states, subtle backgrounds |
--destructive / --destructive-foreground |
Errors, delete actions |
--border / --input / --ring |
Form controls |
--card / --card-foreground |
Card backgrounds |
--accent / --accent-foreground |
Hover states, highlights |
About 15 tokens total. Define them once and every shadcn component (Button, Card, Dialog, DataTable, Sidebar, all 50+) renders with your colors. No component edits needed.
Color spaces: why oklch() won
shadcn v2 switched the default color space from hsl() to oklch(). Two reasons:
- Perceptual uniformity —
oklch(0.55 0.27 262)andoklch(0.55 0.27 145)(a blue and a green) look the same brightness. Withhsl(), they don't. Means your dark mode actually feels balanced when you swap hues. - Wider gamut —
oklch()can express colors P3 displays can show that sRGB can't. Modern Macbooks and iPhones render saturated brand colors more vividly.
You don't have to use oklch(). Tailwind v4 accepts any CSS color. But for a CMS admin where users may swap themes, perceptual uniformity is worth the 5 minutes it takes to learn.
The Pattern: Three Themes, One Build
Here's the actual structure we use in our CMS admin:
/* resources/css/theme.css */
@import "tailwindcss";
/* Default theme — blue */
@theme {
--color-background: oklch(1 0 0);
--color-foreground: oklch(0.15 0.02 250);
--color-primary: oklch(0.55 0.27 262);
--color-primary-foreground: oklch(0.98 0.01 250);
--color-secondary: oklch(0.96 0.01 250);
--color-muted: oklch(0.96 0.01 250);
--color-border: oklch(0.92 0.01 250);
--color-ring: oklch(0.55 0.27 262);
--color-card: oklch(1 0 0);
--color-destructive: oklch(0.6 0.24 25);
--color-accent: oklch(0.96 0.01 250);
}
/* Purple theme override */
[data-theme="purple"] {
--color-primary: oklch(0.6 0.22 295);
--color-ring: oklch(0.6 0.22 295);
}
/* Soft purple (Unfold brand variant) */
[data-theme="unfold"] {
--color-primary: oklch(0.7 0.13 280);
--color-ring: oklch(0.7 0.13 280);
}
/* Dark mode overrides — apply to any theme */
.dark {
--color-background: oklch(0.15 0.02 250);
--color-foreground: oklch(0.95 0.01 250);
--color-card: oklch(0.18 0.02 250);
--color-border: oklch(0.25 0.02 250);
}
.dark[data-theme="purple"] {
--color-primary: oklch(0.7 0.2 295);
}
That's a full theming system. Maybe 80 lines once you cover all 15 tokens × 3 themes × dark mode. No tailwind.config.js. No build step to add a fourth theme — just append a new [data-theme="x"] block.
Switching themes at runtime
A 5-line React hook handles it:
// hooks/use-theme.ts
export function useTheme() {
const setTheme = (theme: 'default' | 'purple' | 'unfold') => {
document.documentElement.dataset.theme = theme
localStorage.setItem('theme', theme)
}
return { setTheme }
}
User clicks a theme picker, data-theme changes on <html>, every shadcn component re-paints with new colors in one frame. No page reload. No fetching new CSS.
What This Solves for a CMS
Every CMS that lets users customize the admin runs into the same problem: how do you ship enough flexibility without becoming a theme engine?
The traditional answers:
- WordPress — Admin themes plugin, ~30 free themes, all override CSS specificity wars with the core admin. Most break on minor WP updates.
- Strapi — Admin extension API, requires a custom build of the admin panel for each customization. Gets ejected from the upstream upgrade path.
- Contentful — No admin theming. Single look, take it or leave it.
- Payload CMS — Custom CSS file you can override, but components themselves aren't yours to modify.
With Tailwind v4 + shadcn/ui, theming becomes a non-feature. You don't ship a "theme system" — you ship a token contract. Anyone running your CMS can:
- Open
theme.css - Override 15 CSS variables under a new
[data-theme="..."]selector - Save
Done. No build, no admin extension API, no plugin marketplace. The component library doesn't need a single line of code change because it never hardcoded any colors.
For agencies running multiple client sites, this is the difference between "we customize the CMS for each client" and "we charge for theming because it's actually fast now."
Tradeoffs and Honest Limits
This pattern isn't free. A few things to know before adopting it:
1. Tailwind v4 is new. Released October 2024. If your team uses v3 plugins like @tailwindcss/typography heavily, check compatibility before migrating. Most have v4 versions, but a few haven't shipped yet.
2. CSS variable tokens won't fix bad component design. If a developer hardcodes text-blue-500 somewhere instead of text-primary, that color won't theme. Code review matters.
3. Browser support: oklch() needs ~2022+ browsers. Chrome 111, Safari 15.4, Firefox 113. If you're supporting IE11 or older Chromebooks, fall back to hsl().
4. shadcn updates can change the token contract. They've added new tokens (--popover, --chart-1 through --chart-5) over the last year. Track the changelog when you upgrade your components/ui/ directory.
5. Tailwind v4 PostCSS plugin order matters. Put @import "tailwindcss" before any other @import. Variables defined before the import won't be picked up by @theme.
None of these are dealbreakers — but if you've been burned by promise-of-flexibility theme systems before, treat the migration as a 1-2 day project, not 1 hour.
A Quick Comparison: v3 vs v4 Theming
For anyone migrating from a v3 setup, here's what changes in concrete terms:
| Concern | Tailwind v3 + shadcn (old) | Tailwind v4 + shadcn (new) |
|---|---|---|
| Config location | tailwind.config.js |
app.css @theme block |
| Add a theme | Edit JS config + rebuild | Add [data-theme="..."] CSS block |
| Color space | hsl() |
oklch() (default) |
| Theme switching | Class swap on root | data-theme attribute on root |
| Build time impact | Each theme = full rebuild | Zero — runtime only |
| Lines of theme code | ~150-200 (multiple files) | ~80 (single CSS file) |
| Dark mode | dark: variant |
.dark selector + token overrides |
The migration itself is mostly mechanical — move colors from JS into CSS, swap classnames where you used dark:bg-X patterns, regenerate any shadcn components you've customized. We migrated our admin (50+ components, 183 pages) in about 6 hours.
How to Implement This in Your Own Project
If you're starting fresh:
- Initialize Tailwind v4 —
npm install tailwindcss@latest, follow the v4 setup guide - Install shadcn/ui v2 —
npx shadcn@latest init, pick the New York or Default style - Move your tokens to a
theme.css— 15 semantic tokens, oklch values - Add a theme picker component — toggle
data-themeon<html> - Test in dark mode — flip the
.darkclass and verify every token has a dark variant
If you're migrating from v3 + older shadcn:
- Read the Tailwind v4 upgrade guide first — there are about 8 syntax changes
- Update your shadcn components —
npx shadcn@latest add buttonwill overwrite to the v2 versions - Move
tailwind.config.jscolors into@theme— delete the JS config when done - Run
npm run buildand grep for hardcoded color classes in your codebase
Most of the migration is replacing hsl(var(--primary)) patterns with the simpler var(--color-primary) form, since v4's @theme syntax wraps that automatically.
Frequently Asked Questions
Can I still use Tailwind v3 with the latest shadcn/ui?
No. shadcn/ui v2 components reference Tailwind v4-style tokens (var(--color-primary) resolved through @theme). If you're on v3, stay on the older shadcn snapshot until you migrate.
Does this pattern work outside React?
Yes. The token contract is pure CSS — Vue, Svelte, plain HTML, even a Laravel Blade admin can consume the same --color-primary token. shadcn/ui itself is React-only, but the theming pattern is framework-agnostic.
How many themes can I realistically ship? Unbounded. Each theme is ~10-20 lines of CSS overrides. We tested with 12 themes — total CSS bundle grew by ~3KB. The bottleneck is design taste, not technical cost.
What about per-user themes (different colors per logged-in admin)?
Set data-theme from the user's profile setting on page load (in your layout or a server-rendered hydration script). No backend infrastructure needed beyond storing one string per user.
Does dark mode require duplicating every theme?
No. Define dark mode token overrides once (.dark { ... }) and they apply across all themes. Only override per-theme dark variants if a brand color needs special treatment in dark mode.
Can I use this with a TypeScript-first CMS workflow? Yes — and it's a strong combination. Define your token names in a TS union type for theme-picker components. The CSS variables themselves are runtime, but you get type safety where it matters.
Bottom Line
Tailwind v4 + shadcn/ui v2 isn't a faster way to do the same theming. It's a different model: design tokens as CSS variables, components as token consumers, themes as 15-line overrides. For a CMS where flexibility is a selling point, it removes 90% of the code that used to be "theme engine" and replaces it with a CSS file the customer can edit themselves.
If you're choosing a CMS stack today and care about admin DX, this combo is one of the strongest reasons to look beyond WordPress and traditional headless platforms — both of which lock you out of the admin's component layer entirely.
We use this pattern in Unfold CMS — see the live admin demo, or if you want the full component breakdown, check our writeup on 50 shadcn components in production.
Related: The CMS Built on shadcn/ui: Why It Matters · Laravel + React + shadcn/ui: The Modern CMS Stack · Why Your shadcn/ui Admin Template Should Be a Full CMS
Sources & Methodology
- Tailwind CSS v4 announcement and upgrade guide — tailwindcss.com (2024-2025)
- shadcn/ui v2 release notes — ui.shadcn.com (2024-2025)
- OKLCH color space spec — CSS Color Module Level 4
- First-hand experience: migrating Unfold CMS admin (50+ components, 183 pages) from Tailwind v3 + shadcn v1 to Tailwind v4 + shadcn v2 over 6 hours in early 2026
- Browser support data: caniuse.com (
oklch()color function, December 2024)
Share this post:
Leave a Comment
Please log in to leave a comment.
Don't have an account? Register here