Guides · October 4, 2023
Theming a shadcn Storefront: Multi-Brand from One Codebase
Shadcn theming for multi-brand storefronts works best as CSS variable tokens layered per-brand at the root, not per-component overrides. Here is the token architecture, the Tailwind v4 wiring, and the pitfalls that break it at scale.
By Polo Themes
The fastest, most maintainable way to run several brands off one shadcn/ui codebase is to push every visual decision into CSS custom properties scoped to a root attribute — a data-brand or data-theme value on html or body — and let component code stay brand-agnostic forever. shadcn components already read color, radius, and spacing through CSS variables rather than hardcoded Tailwind classes, which means the theming seam Anthropic-style design systems usually have to bolt on afterward is already built in. Get the token layer right once, and adding a fourth or fifth brand becomes a content and palette exercise, not a refactor.
This guide walks through that architecture end to end: how shadcn's variable-based theming actually works under the hood, how to structure tokens so N brands do not mean N copies of every component, how to wire it into Tailwind v4's CSS-first config, and where multi-brand theming quietly breaks — chart colors, third-party embeds, email templates, and dark mode drift being the recurring offenders.
Why shadcn is unusually well-suited to multi-brand work
shadcn/ui is not a component library you install from npm — it is a CLI that copies component source directly into your repository, built on Radix primitives and styled with Tailwind classes that reference CSS variables (bg-background, text-foreground, border-input, and so on) instead of literal color values. That last detail is the whole trick. A Button component never says bg-blue-600; it says bg-primary, and --primary is defined once, centrally, as a CSS variable. Swap the variable and every button, badge, and focus ring across the entire app repaints without touching a single component file.
Compare that to a component library that bakes color classes into markup, or a CSS-in-JS system where theme values are threaded through a React context provider at runtime. Those approaches can do multi-brand too, but they either require prop-drilling a theme object through every styled component or paying a runtime cost to resolve theme values on every render. shadcn's approach costs nothing at runtime — the browser's own CSS cascade does the brand switching, which is why it composes so cleanly with static generation and edge rendering in Next.js.
Step 1: Design the token layer, not the component layer
Before touching a single component, decide what actually varies between brands and what stays fixed. In nearly every multi-brand storefront, three layers exist, and conflating them is the single most common mistake:
- Primitive tokens — raw color scales, a type ramp, spacing scale, radius scale. These rarely differ per brand; a brand usually reuses the same neutral gray scale and just swaps which hue sits in the "primary" slot.
- Semantic tokens — the shadcn defaults like --background, --foreground, --primary, --primary-foreground, --muted, --accent, --destructive, --border, --ring, --radius. These are what components actually consume, and this is the layer that changes per brand.
- Brand tokens — a small, brand-specific set (logo mark color, a hero gradient, maybe a secondary accent for promotional badges) that most brands need but that does not belong in the semantic layer because it is not a "role," it is brand identity.
The discipline that keeps this scalable is simple: components only ever read semantic tokens. A product card never says "if brand is Acme, use orange." It says bg-primary, full stop. The brand-to-color mapping lives entirely in the CSS variable definitions, one level up, where it belongs. This is the same two-tier token pattern (primitive → semantic) that shows up in most mature design systems, and it is the reason a well-tokenized shadcn app can support a new brand launch as a content task rather than an engineering sprint.
Step 2: Wire brands through a root attribute, not a class per component
shadcn's default setup (via components.json and the generated globals.css) defines light and dark variants using :root and .dark. Multi-brand extends that exact pattern with one more axis: a data-brand attribute on the html element, layered so brand and color-mode combine independently.
In practice that looks like a CSS structure along these lines: a :root block holding sane fallback values, then [data-brand="acme"] and [data-brand="nova"] blocks that redefine the semantic variables, and separate .dark or [data-theme="dark"] blocks — combined via attribute selectors like [data-brand="acme"].dark — for brands that need bespoke dark-mode values rather than an automatic darken. Tailwind v4's CSS-first configuration makes this especially clean: you declare your theme tokens with an @theme block that maps variable names to Tailwind utility names once, and every brand override is then just a plain CSS variable reassignment with no rebuild of the Tailwind config required per brand.
Setting the attribute itself is a one-line concern at the app boundary — resolved from the request host, a subdomain, a path segment, or a stored preference — and written onto the root element before first paint (a small inline script or a server-rendered attribute, the same technique used for light/dark mode) so there is no flash of the wrong brand. The component tree underneath never needs to know which brand is active; it only ever asks for primary, accent, or destructive and gets whatever the active data-brand block has defined.
Step 3: Keep component variants brand-neutral
A subtle failure mode: teams start clean with semantic tokens, then under deadline pressure add a one-off brand check inside a component — {brand === 'nova' && 'rounded-full'} — because a single brand wants pill-shaped buttons. Do this twice and the component library stops being reusable across brands, which defeats the entire point of the exercise.
The correct escape hatch is a token, not a conditional. If radius genuinely varies by brand, --radius is already a shadcn variable for exactly this — set it per brand in the same attribute block used for color, and every component that uses rounded-[--radius]-style utilities (or the CSS variable directly) follows automatically. If a brand needs a genuinely different component shape — not just different tokens — that is a legitimate case for a brand-specific component variant registered through class-variance-authority (cva), which shadcn already uses for variant handling. Route that decision through an explicit variant prop resolved once at the layout level, not scattered brand conditionals inside leaf components.
Step 4: Typography and imagery need the same discipline
Color gets the most attention, but a brand's identity is at least as much about type and imagery, and both benefit from the same token-first treatment. Define a --font-sans (and --font-display if brands use a distinct headline face) CSS variable per brand block, loaded via next/font with each brand's chosen font family, and reference it through Tailwind's font-family utility mapped in the @theme block rather than hardcoding a font name in component CSS.
Imagery is trickier because it cannot live in a CSS variable the same way a color can, but the same attribute-driven pattern still helps: resolve a small brand-config object (logo asset path, hero image set, favicon) from the same data-brand value at the layout root, and pass it down through props or a lightweight context — never re-derive "which brand am I" logic inside individual page components.
Where multi-brand theming quietly breaks
A few places consistently get missed because they sit outside the component tree that shadcn actually themes:
- Charts and data visualizations. Libraries like Recharts or visx are usually configured with literal hex values in a chart config object, entirely bypassing your CSS variables. Route chart color scales through the same CSS custom properties (read at runtime via getComputedStyle, or duplicated into a small brand-aware JS palette map) so a brand switch does not leave dashboards showing the wrong hue.
- Email templates. Transactional email (order confirmations, shipping updates) is typically rendered with inline styles for client compatibility and cannot inherit your app's CSS variables at all. Multi-brand email needs its own token resolution step at render time, not a shared stylesheet.
- Third-party embeds. Payment widgets, chat widgets, and review widgets often ship their own theming API (a JS config object, not CSS) — these need to be explicitly re-themed per brand rather than assumed to inherit anything.
- Dark-mode drift per brand. Automatically inverting a brand's light palette for dark mode frequently produces muddy, low-contrast results for saturated brand colors. Budget time to hand-tune dark-mode variable values per brand rather than relying purely on an automatic lightness inversion.
- Favicon and metadata. Next.js resolves <head> metadata (including favicon and Open Graph images) at build or request time — multi-brand deployments need this wired to the same brand-resolution logic used for the CSS attribute, or brand B will ship with brand A's favicon.
A pragmatic rollout order
If you are retrofitting multi-brand support onto an existing shadcn app rather than building it in from day one, this order tends to minimize rework: first, audit every component for hardcoded color classes and migrate them to semantic token classes — this alone often exposes 80% of the brand-coupling problems. Second, introduce the data-brand attribute and a single second brand's token block, even before that brand is real, purely to prove the seam works. Third, extend to typography and imagery resolution. Fourth, sweep the "quietly breaks" list above — charts, email, embeds, favicon — since these are the parts that pass code review clean and then surface as bugs in production weeks later.
This same token-first thinking is exactly what a well-structured Figma-to-code handoff needs to preserve — if your design source defines color as named styles rather than raw hex fills, translating that into a semantic CSS variable layer is close to mechanical. Our Figma UI kits are built with that kind of structured, named-style token layer specifically so a handoff like this does not require reverse-engineering the palette from screenshots.
Frequently Asked Questions
Do I need a separate Tailwind config per brand?
No, and trying to maintain one is a sign the architecture has gone sideways. With Tailwind v4's CSS-first @theme approach, you define utility-to-variable mappings once; brands only ever override the CSS variable values inside attribute-scoped blocks, not the Tailwind configuration itself.
Should brand switching happen client-side or server-side?
Resolve it server-side whenever the brand is knowable from the request (subdomain, custom domain, or a signed cookie) so the correct data-brand attribute is present in the initial HTML and there is no flash of the wrong theme. Reserve client-side switching for genuine live-preview or user-toggle scenarios.
How many brands does this pattern realistically scale to?
The CSS-variable-per-attribute approach scales fine to dozens of brands from a maintenance standpoint, since each new brand is just another attribute-scoped CSS block. The real ceiling is usually organizational — how many brands' worth of content, imagery, and copy your team can keep current — not the theming mechanism itself.
Does this approach work with server components and streaming in the Next.js App Router?
Yes — this is one of the advantages over runtime CSS-in-JS theming. Because the brand resolves to a plain HTML attribute and plain CSS variables, there is nothing to resolve inside a client-only theme provider, so it works identically whether a component is a server component, a streamed component, or fully static.