EmpireUI
Get Pro
← Blog9 min read#multi-brand#design system#theming

Multi-Brand Design System: One Component Library, N Themes

Ship one component library that powers multiple brands. Here's how to architect CSS token layers, theme switching, and white-label delivery without duplicating a single component.

Multiple color palettes and design tokens on a developer screen

Why Most Multi-Brand Systems Fall Apart

The setup always starts the same way. You have Brand A shipping, the client wants Brand B white-labeled by Q3, and someone on the team says "let's just fork it." Six months later you're maintaining two repos, both drifting, both broken in different ways. Sound familiar?

In practice, the problem isn't the components — it's that teams conflate *style* and *structure*. When you fork a repo to create a brand variant, you're duplicating structure when you only needed to swap style. Everything downstream — bug fixes, accessibility patches, new features — now has to land in two places. That's how you end up with Button.tsx and ButtonBrandB.tsx living three directories apart.

The fix is a strict token-first architecture. Your components never reference raw color values. They never hardcode #1a1a2e or font-size: 16px. Every visual decision flows through a named token layer — and that layer is the only thing that changes between brands. One codebase. N config files.

Worth noting: this isn't a new idea. Salesforce's Lightning Design System introduced the concept back around 2015. What's changed is that the tooling — CSS custom properties, design token build pipelines, CSS layers — has caught up to the idea. You can do this properly now without inventing infrastructure from scratch.

The Token Layer Architecture

Think of your token system as three tiers. Tier 1 is primitives — raw values that have no semantic meaning. --color-blue-500: #3b82f6. Nothing in your component code ever touches tier 1 directly. These are just your palette.

Tier 2 is semantic tokens. --color-primary: var(--color-blue-500). This is where brand intent lives. Your components only ever reference tier 2 tokens. When Brand B needs green primaries instead of blue, you swap --color-primary at this tier — primitives untouched, components untouched.

Tier 3 is component tokens. --button-bg: var(--color-primary). Optional, but powerful for highly branded components. It lets a specific brand override just the button background without touching the broader primary color cascade. You'll want this when clients have strong opinions about individual components but you don't want to break the global token map.

Here's a minimal file structure that makes this concrete: `` tokens/ primitives.css # raw values, shared across all brands semantic.default.css # default brand semantic mapping semantic.brand-b.css # brand B overrides semantic.brand-c.css # brand C overrides components/ Button.tsx # references only semantic tokens Card.tsx ... ``

That said, the file structure is the easy part. The hard part is discipline — making sure no one on the team hardcodes color: #6366f1 inside a component. Lint rules help. A color system reference helps more.

CSS Custom Properties: The Runtime Engine

CSS custom properties (supported in all browsers since 2017) are what make runtime theme switching cheap. You define your semantic token layer on :root or a brand wrapper element, and every component inherits the right values automatically. No JavaScript. No re-renders. Just cascade.

Here's the pattern in practice: ``css /* tokens/semantic.default.css */ :root { --color-primary: #6366f1; --color-surface: #ffffff; --color-text-primary: #111827; --radius-base: 8px; --shadow-card: 0 4px 24px rgba(0,0,0,0.08); } /* tokens/semantic.brand-b.css */ [data-brand="brand-b"] { --color-primary: #10b981; --color-surface: #f0fdf4; --color-text-primary: #064e3b; --radius-base: 2px; /* brand B has sharp corners */ --shadow-card: 0 2px 8px rgba(0,0,0,0.12); } ` Then your component: `tsx // components/Button.tsx export function Button({ children, ...props }) { return ( <button style={{ backgroundColor: 'var(--color-primary)', borderRadius: 'var(--radius-base)', color: '#fff', padding: '10px 20px', }} {...props} > {children} </button> ); } ``

Switching brands at runtime is a data-brand attribute swap on the root element — that's it. You can drive it from URL params, user session data, or a build-time env variable. All three brands work from the same deployed bundle if you want them to.

One more thing — if you're building something with heavy visual styling like glassmorphism or neumorphism UI, the token layer matters even more. Check out how glassmorphism components handle backdrop-filter and transparency values — those are exactly the kind of properties you'd want to token-ize rather than hardcode per brand.

Building a Theme Provider in React

The CSS approach handles 90% of your theming needs. But sometimes you need JavaScript to know the current brand — for conditional logic, analytics, loading the right font file. A thin React context layer covers that without overengineering anything.

// lib/theme-context.tsx
import { createContext, useContext, useEffect } from 'react';

type Brand = 'default' | 'brand-b' | 'brand-c';

const ThemeContext = createContext<Brand>('default');

export function ThemeProvider({ brand, children }: { brand: Brand; children: React.ReactNode }) {
  useEffect(() => {
    document.documentElement.setAttribute('data-brand', brand);
    return () => document.documentElement.removeAttribute('data-brand');
  }, [brand]);

  return <ThemeContext.Provider value={brand}>{children}</ThemeContext.Provider>;
}

export const useBrand = () => useContext(ThemeContext);
```

Then in your app entry:

```tsx
// app/layout.tsx (Next.js 14+)
import { ThemeProvider } from '@/lib/theme-context';

export default function RootLayout({ children }) {
  const brand = process.env.NEXT_PUBLIC_BRAND as Brand ?? 'default';
  return (
    <html>
      <body>
        <ThemeProvider brand={brand}>{children}</ThemeProvider>
      </body>
    </html>
  );
}

Honestly, this is all you need for most white-label SaaS products. The context gives you the brand name in JS, the CSS custom properties handle the visual layer, and your components stay clean. You're not shipping theme logic into every leaf node.

Quick aside: be careful with SSR. If you're reading brand from a cookie or header, make sure the data-brand attribute is set server-side too — otherwise you'll get a flash of the default theme on first load. In Next.js, that means setting the attribute in layout.tsx via a server component, not in a useEffect.

Font and Spacing Tokens: The Details That Sell It

Color gets all the attention, but the brands that feel truly distinct from each other differ in typography and spacing as much as color. A 16px base font on a 1.5 line-height feels completely different from 14px on 1.7 — same content, different personality.

Add these to your semantic token set: ``css :root { /* Typography */ --font-family-base: 'Inter', sans-serif; --font-size-base: 16px; --line-height-base: 1.5; --font-weight-heading: 700; /* Spacing scale */ --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-6: 24px; --space-8: 32px; } [data-brand="brand-b"] { --font-family-base: 'DM Sans', sans-serif; --font-size-base: 15px; --line-height-base: 1.65; --font-weight-heading: 600; /* spacing unchanged — same grid, different feel */ } ``

Load fonts lazily per brand. Don't dump all N font families into the <head> on every page load. In Next.js you can conditionally include next/font loaders based on the brand env variable at build time, or load font URLs from the token config at runtime. Either way, you're looking at maybe 40-80KB per font family — it adds up if you have 5 brands.

The spacing system is worth keeping consistent across brands more often than not. Clients want their brand to *look* different, not feel structurally alien. Shared spacing creates the underlying rhythm that makes your UI feel intentional even when colors and type are totally different. Check the box shadow generator for a feel of how small value changes — even just 4px of blur — dramatically shift the aesthetic.

Build Pipeline: Shipping Per-Brand Bundles

Runtime switching is great for demos and admin previews. For production white-label deployments, you usually want per-brand builds — separate URLs, separate CSS bundles, no brand-switching overhead at runtime. This is simpler than it sounds.

If you're on Next.js, the cleanest approach is a build-time env variable driving which token file gets imported: ``js // next.config.js const brand = process.env.BRAND ?? 'default'; module.exports = { env: { NEXT_PUBLIC_BRAND: brand, }, // optionally alias the token import webpack(config) { config.resolve.alias['@/tokens/semantic'] = path.resolve(__dirname, tokens/semantic.${brand}.css); return config; }, }; ` Then your CI pipeline just runs: `bash # Deploy Brand B BRAND=brand-b next build && next export -o dist/brand-b # Deploy Brand C BRAND=brand-c next build && next export -o dist/brand-c ``

Each brand gets its own static output directory. Deploy them to separate subdomains or paths — brand-b.yourproduct.com, brand-c.yourproduct.com. CDN, edge caching, all the usual stuff applies normally. The builds are completely independent after this point.

Look, if you have more than about 8-10 brands, this matrix of builds can get slow. That's when you look at CSS-only runtime switching with a single build and just ship one bundle. Under 8 brands, per-brand builds are cleaner — smaller CSS, no runtime token loading, easier debugging. Pick your threshold based on your actual brand count, not theoretical scale.

When Themes Need More Than Tokens

Tokens handle 95% of it. But sometimes Brand C has a completely different header layout — a sidebar nav instead of a top bar. Or Brand B's card component has an extra badge that the default component doesn't have. At that point you need component-level composition, not just token overrides.

The pattern here is slot-based composition with a brand registry: ``tsx // lib/brand-registry.ts import { DefaultHeader } from '@/components/DefaultHeader'; import { BrandBHeader } from '@/components/BrandBHeader'; const registry = { default: { Header: DefaultHeader }, 'brand-b': { Header: BrandBHeader }, }; export function getBrandComponents(brand: string) { return registry[brand] ?? registry.default; } ` Then in your layout: `tsx import { getBrandComponents } from '@/lib/brand-registry'; import { useBrand } from '@/lib/theme-context'; export function AppShell({ children }) { const brand = useBrand(); const { Header } = getBrandComponents(brand); return ( <div> <Header /> <main>{children}</main> </div> ); } ``

Keep the registry small. If every brand has custom versions of 20 components, you've just built N separate apps and called them one. The registry should hold *exceptions*, not the rule. When a brand needs a layout change, ask first whether a token can express it — maybe --header-position: sticky vs fixed is enough. You'd be surprised how far token logic can stretch.

For heavily styled UIs — think cyberpunk or vaporwave aesthetics — the registry approach becomes more useful because the visual difference really is structural, not just color. A cyberpunk header might have a completely different grid layout with scanline overlays. That's not a token. That's a component swap. Know the difference and your architecture stays clean. Browse the full Empire UI component library to get a sense of how distinct visual systems can be — they're all built on the same structural skeleton.

FAQ

Can I use CSS custom properties for theme switching in older browsers?

Custom properties have had full browser support since 2017 — you're safe. IE11 is the only holdout, and if you're still targeting IE11 in 2026, token-based theming is probably not your biggest problem. For everything else, the cascade just works.

Should semantic tokens reference other tokens or raw values?

Semantic tokens should always reference primitives, not raw values. Writing --color-primary: var(--color-blue-500) keeps your palette in one place. If you hardcode --color-primary: #3b82f6 in your semantic layer, you've created two sources of truth for the same color — and they'll drift.

How do I handle per-brand fonts without loading all of them on every page?

At build time, conditionally import only the brand's font. With Next.js next/font, each brand build includes only the relevant font loader. For runtime switching, lazy-load the font stylesheet via a <link> tag when the brand changes. Either way, don't dump all font families into a shared _document.tsx.

What's the difference between design tokens and CSS variables?

Design tokens are the *concept* — named, platform-agnostic style decisions. CSS custom properties are one *implementation* of that concept on the web. Your token system might also generate iOS Swift color assets or Android resource files from the same source. CSS variables are just the web delivery mechanism.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Dark Mode Color Tokens: Building a Theme That Doesn't Break EverythingSpacing System Design: Base Unit, Scale, Consistent Component GapsMonorepo Design System: Shared Packages, Storybook, PublishingDark Mode Color Palette System: Semantic Tokens That Actually Work