Theming a shadcn CMS: 3 Themes from One Codebase
Tailwind v4 CSS variables make shadcn admin theming a one-file change
Three themes. One codebase. No build step per theme. No duplicated components. Adding a fourth theme is one file.
That's how theming works in a shadcn-based CMS once you've leaned into Tailwind v4 CSS variables — and it's one of the things that ages best across a year of production. This post walks through how UnfoldCMS ships three themes from one source tree, why CSS variables beat the old @apply / theme-config approach, and how to add a custom theme to your own fork in about ten minutes.
Disclosure: I work on UnfoldCMS. The patterns below are the ones that survive in cms/resources/css/theme.css today.
TL;DR — what one codebase + multiple themes looks like
UnfoldCMS ships three production themes: default (brand blue, #2563EB), purple, and unfold (soft purple, #938DE5). Switching between them is a data-theme attribute on the <html> element. No build step. No theme-specific assets. No component changes. The 51 shadcn components stay identical; the CSS variables underneath them change. Adding a fourth theme = adding a new [data-theme="name"] block to one file with the colour variables you want.
| Component | Default (blue) | Purple | Unfold (soft purple) |
|---|---|---|---|
| Primary color | oklch(0.55 0.27 262) |
Purple variant | #938DE5 |
| Light mode bg | White / light gray | White / light gray | White / light gray |
| Dark mode bg | Dark slate | Dark slate | Dark slate |
| Buttons, links, focus rings | Brand blue | Purple | Soft purple |
The architecture — Tailwind v4 + CSS variables
The pattern is straightforward once you've seen it: every colour, radius, spacing token in the design system maps to a CSS variable. Tailwind v4 reads those variables at runtime. Switching theme = changing the variables. The components reading them don't change.
Concretely:
/* resources/css/theme.css — simplified */
:root {
--primary: oklch(0.55 0.27 262);
--primary-foreground: oklch(0.98 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.13 0.03 262);
--border: oklch(0.92 0 0);
--radius: 0.5rem;
/* ...etc */
}
[data-theme="purple"] {
--primary: oklch(0.6 0.25 295);
--primary-foreground: oklch(0.98 0 0);
}
[data-theme="unfold"] {
--primary: oklch(0.7 0.12 280);
--primary-foreground: oklch(0.13 0.03 262);
}
shadcn components consume these via Tailwind classes — bg-primary, text-primary-foreground, border-border. The classes don't know about themes; they read whatever variable is set in the cascade.
This is the architecture Tailwind v4 was built for. It's also the architecture shadcn/ui assumes. When the two line up, theming becomes a one-file change.
What the old way looked like (Tailwind v3 + theme config)
Before v4, the standard approach was a JavaScript theme config:
// tailwind.config.ts (v3)
module.exports = {
theme: {
extend: {
colors: {
primary: '#2563EB',
// ...
},
},
},
};
To switch themes, you needed:
- A separate build per theme (different config → different CSS), OR
- JavaScript runtime style overrides, OR
@apply-laden components that read theme-specific classes
All three are worse than CSS variables. The build-per-theme approach inflates bundle sizes; the JS-runtime approach hits the browser at parse time; the @apply approach loses portability. None of them survived contact with production.
Tailwind v4's runtime variable resolution killed all three.
The migration cost — what changed in our 51 components
When we migrated UnfoldCMS from Tailwind v3 + JS theme config to Tailwind v4 + CSS variables (mid-2025), every component had to be touched. Specifically:
- Replaced
@applyblocks with raw Tailwind utilities that readvar(--token)indirectly. About 40% of components had at least one@apply. - Replaced theme-config token references (
bg-primarywas fine;bg-brand-50had to becomebg-primary/5or a custom variable). - Moved colour definitions from
tailwind.config.tstotheme.css. All colours, radii, spacings became CSS variables. - Added the
[data-theme]selectors for non-default themes.
Total work: about two weeks. After the migration, adding the third theme (unfold) took 20 minutes. Adding a hypothetical fourth would be a similar 10-30 minutes. The migration paid for itself within the first new theme.
See Tailwind v4 + shadcn/ui: Building a Themeable CMS for the longer migration write-up, and shadcn/ui in Production: 1 Year, 51 Components, 205 Admin Pages — Lessons for the broader year-one perspective.
How to add a fourth theme in 10 minutes
If you have a UnfoldCMS install (or any shadcn + Tailwind v4 codebase), adding a theme:
- Open
resources/css/theme.css. - Add a new
[data-theme="yourname"]block with the colour variables you want overridden. You only need to override what differs from the default — usually--primary,--primary-foreground, sometimes--accentand--ring.
[data-theme="emerald"] {
--primary: oklch(0.6 0.18 160);
--primary-foreground: oklch(0.98 0 0);
--accent: oklch(0.85 0.06 160);
--ring: oklch(0.6 0.18 160);
}
- Set the theme attribute on the
<html>element (or wherever your theme root lives):
<html data-theme="emerald">
- Reload. Every shadcn component in the admin now uses emerald as the primary colour. No build step. No component changes. The 205 admin pages don't know anything happened — they read
bg-primaryand get whatever you set.
What doesn't change between themes
Critical caveat: theming covers colours, radii, and a small set of design tokens. It doesn't cover:
- Component structure. If you want one theme to have a sidebar on the left and another on the right, that's not theming — that's two different admin shells.
- Typography choices. Theming changes the colour of text, not the font. Font swaps live in the global CSS, not the theme blocks.
- Layout density. A "compact" theme that shrinks every padding by 20% is doable in theory, but in practice we found it hurts more than it helps. Components stop looking like themselves at non-default densities.
For deeper customisation than colour theming, you're back to fork-and-modify. See Customizing the shadcn Admin: Fork-and-Modify Beats Vendor Widgets for that approach.
Why three themes survived and the rest didn't
We shipped five themes initially: default blue, purple, green, slate, unfold. Three survived production use; two were cut. The pattern:
- Default blue survived because it's the brand. Most installs use it.
- Purple survived because it's the most-requested alternate; the second-most installs use it.
- Unfold (soft purple) survived because it's a brand variant for the parent product.
- Green was cut. It got <2% usage in our install telemetry and design felt off in dark mode.
- Slate was cut. It looked like a "no theme chosen" state more than a deliberate choice.
The lesson: don't ship themes you can't defend. Each theme is a maintenance surface (every new component has to look right in all themes). Three themes are easy to maintain; five strained the team. If we'd started over, we'd have shipped two and added the third only when users asked for it.
When CSS-variable theming isn't enough
For most CMS customisation, CSS variables cover the need. They don't cover:
- Brand-specific full re-skins where colours, type, density, and layout all change. That's a fork.
- Dark mode variations beyond the default (we ship light + dark per theme, but a "high-contrast" or "low-light reading" mode would be a third variant per theme — possible, but a fork-level change).
- Per-customer themes where each install has a unique colour palette. Possible via runtime variable injection, but adds complexity. Worth it for white-label use cases; overkill for everything else.
If your need is "I want a few themes that mostly differ on primary colour and accents," CSS variables are perfect. If your need is "I want each customer install to look like their brand," you're in deeper territory — fork-and-modify or a runtime injection pattern.
People Also Ask
How many themes does UnfoldCMS ship?
Three: default (brand blue, #2563EB), purple, and unfold (soft purple, #938DE5). All three live in one theme.css file and switch via a data-theme attribute on <html>. No build per theme.
How do I add a custom theme to UnfoldCMS?
Add a new [data-theme="yourname"] block to resources/css/theme.css with the colour variables you want overridden. Set the attribute on <html>. Reload. Total time: under 10 minutes for a basic colour theme.
Does theming require Tailwind v4?
For the CSS-variable runtime approach: yes, v4. Tailwind v3 supports CSS variables but the integration is awkward (you set them up manually and lose some utility-class features). v4 is built around variables natively. If you're starting fresh in 2026, use v4.
What about dark mode?
shadcn ships light + dark modes via the same CSS-variable system. Each theme block defines both light and dark token sets. Switching between light and dark uses a separate class="dark" on <html> (independent of data-theme). The two combine — "purple dark mode" = <html class="dark" data-theme="purple">.
Can I theme the public site separately from the admin?
Yes. The admin and the public site can use the same theme system or different ones. In UnfoldCMS, both share theme.css by default, but you can scope themes to specific routes by setting data-theme only on certain layouts.
Bottom line
If you've been theming a CMS with build-per-theme, @apply ladders, or JS-runtime overrides, switching to Tailwind v4 + CSS variables pays back fast. Three production themes from one codebase, no build steps, no duplication, no component touch-ups — this is what good design-system architecture looks like in 2026.
Try the UnfoldCMS demo to see the three themes live. The theme switcher is in the admin settings — toggle between default, purple, and unfold to see what one-file theming buys you. Or see pricing for the source-available tiers if you want to ship your own custom theme.
Sources and methodology
- Theme count — three production themes: default, purple, unfold. Confirmed in
cms/resources/css/theme.css. - Brand color —
#2563EBdocumented incms/CLAUDE.mdbrand section. - Migration cost — two-week estimate drawn from UnfoldCMS internal commit history during the Tailwind v3 → v4 migration mid-2025.
- Cut themes — green and slate were removed based on install telemetry. Numbers approximate.
- shadcn theming docs confirmed at the shadcn/ui theming page.
- Tailwind v4 CSS variables confirmed at the Tailwind v4 docs.
Share this post: