EmpireUI
Get Pro
← Blog7 min read#multi-brand#theming#design-systems

Multi-Brand Theming in React: One Component Library, N Brands

Ship one React component library and theme it across multiple brands without forking code. CSS custom properties, context providers, and Tailwind v4 tricks explained.

Multiple color swatches arranged in a grid representing multi-brand theming variations

Why One Codebase for Multiple Brands Actually Works

Honestly, most teams over-complicate this. They fork the component library per client, then spend the next 18 months keeping three forks of the same Button component in sync while a bug fix in one repo never makes it to the others. It's a maintenance nightmare that compounds over time.

The right mental model is this: your components define *structure and behavior*, not visual identity. A Card component doesn't care if it's rendering for a fintech startup with sharp corners or a wellness brand that loves soft rounded edges. Those decisions belong in a theme layer, not in your JSX.

With CSS custom properties and a thin React context layer, you can ship one build artifact and skin it N times. No separate npm packages per brand, no conditional class logic scattered through your components, no if (brand === 'acme') branches anywhere. Just token overrides at the root.

Setting Up Your Token Architecture with CSS Custom Properties

The foundation is a flat set of semantic CSS variables. Not --blue-500, but --color-primary, --color-surface, --radius-md, --shadow-card. The semantic layer is what brands actually swap out. The primitive palette underneath can stay shared.

Here's a minimal token file that covers color, spacing, and shape. In Tailwind v4.0.2 you can reference these directly in your @theme block, which means you get all the utility class generation on top of your custom properties for free.

/* tokens/brand-default.css */
:root {
  --color-primary: #6366f1;
  --color-primary-foreground: #ffffff;
  --color-surface: #ffffff;
  --color-surface-raised: #f8fafc;
  --color-border: rgba(0, 0, 0, 0.08);
  --color-text: #0f172a;
  --color-text-muted: #64748b;

  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 16px;

  --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
  --gap-section: 48px;
  --gap-card: 24px;
}

/* tokens/brand-nova.css — swap only what changes */
[data-brand="nova"] {
  --color-primary: #f97316;
  --color-primary-foreground: #ffffff;
  --color-surface: #fafaf9;
  --radius-md: 2px;
  --radius-lg: 4px;
  --shadow-card: 0 4px 16px rgba(249, 115, 22, 0.12);
}

Notice the [data-brand="nova"] attribute selector. You set data-brand on the root element (or any subtree boundary) and the cascade does the rest. No JavaScript token injection, no runtime style recalculation. This approach also means you can render two branded sections on the same page — useful for agency preview tools or white-label dashboards.

The React BrandProvider: Thin Context, Zero Magic

You'll need a React context provider to make the current brand name available to components that need to branch on non-visual things — icon sets, copy defaults, analytics event prefixes. Keep it thin. The token layer handles 90% of visual differences; the context handles the rest.

// brand/BrandProvider.tsx
import { createContext, useContext, useEffect, type ReactNode } from 'react';

type Brand = 'default' | 'nova' | 'apex' | 'slate';

interface BrandContextValue {
  brand: Brand;
}

const BrandContext = createContext<BrandContextValue>({ brand: 'default' });

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

  return (
    <BrandContext.Provider value={{ brand }}>
      {children}
    </BrandContext.Provider>
  );
}

export const useBrand = () => useContext(BrandContext);

Wrap your app root: <BrandProvider brand={tenantConfig.brand}><App /></BrandProvider>. The useEffect syncs the CSS attribute, so SSR hydration stays clean — the server renders the default tokens, then the client snaps to the correct brand on mount. For fully server-rendered apps you'd instead pass the data-brand attribute directly into your layout HTML based on a cookie or subdomain lookup.

Building Brand-Agnostic Components

The discipline here is resisting the urge to hardcode colors. Every color reference in a component should trace back to a token. That's it. If you find yourself writing bg-indigo-500 in your Tailwind classes, stop and ask: should this be bg-[var(--color-primary)] instead?

For a color system that scales, you want three tiers: primitive values (raw hex/oklch values), semantic tokens (named by role, not value), and component tokens (optional, scoped to specific components). Most teams don't need the third tier until their library has 50+ components with overlapping semantics.

With Tailwind v4's arbitrary property support, you can write bg-[var(--color-surface)] inline, or define a custom utility in your CSS. The cleaner path in large projects is to add Tailwind aliases in your config: colors: { primary: 'var(--color-primary)', surface: 'var(--color-surface)' }. Then bg-surface just works, and it responds to brand switches automatically.

Don't forget interactive states. Your hover and focus rings need to derive from tokens too. rgba(255,255,255,0.15) as a hard-coded overlay value might work for a dark brand but break on a light one. Use --color-primary with opacity modifiers or dedicate a --color-primary-hover token per brand.

Integrating Multi-Brand Theming with Storybook

Testing brand variants in isolation is where most setups fall apart. You fix the default brand, ship it, and two weeks later someone notices the Nova brand's card shadow is clipping on mobile. This is solvable with a Storybook toolbar addon that toggles the data-brand attribute.

Add a global type in .storybook/preview.ts and a decorator that syncs it to the DOM. Then your story matrix can render every component in every brand without any story-level boilerplate. Pair this with Storybook's component library workflow and you've got a solid visual regression baseline.

The real win is that designers can use the Storybook toolbar to switch brands live, which closes the feedback loop dramatically. No more "can you show me what the Nova button looks like?" back-and-forth — they can toggle it themselves. If you're also running visual diff tests, set up a test matrix that runs every story in every brand configuration.

Handling Icons, Typography, and Non-Color Brand Differences

Colors are the easy part. Where multi-brand theming gets genuinely tricky is icons and typography. Brand A uses Feather icons, Brand B licensed a custom icon set, Brand C wants to use Lucide. Baking any of these into your components directly breaks the abstraction.

The solution is an icon registry. Define a useIcon(name) hook that reads from a brand-specific icon map. Your components call useIcon('chevron-right') and never import an icon directly. Each brand's config registers the icon set it uses. A solid icon system in React usually covers this pattern in detail, but the short version is: keep icons out of component internals.

Typography is handled cleanly via tokens too. --font-heading, --font-body, --font-size-base, --line-height-normal. Load brand-specific fonts in your brand CSS file alongside the color tokens. The @font-face declarations live in brand-nova.css right next to --color-primary. Everything scoped, everything co-located.

What about spacing? If Brand B has a "tighter" feel, you can override --gap-card from 24px down to 16px at the brand level. Check out how a consistent spacing system in CSS sets this up — the principles transfer directly to the token override pattern.

Loading Brand Tokens at Runtime vs. Build Time

There are two deployment models and the right one depends on how many brands you're serving. If it's 2-5 fixed brands, just include all brand CSS files in your bundle and let the attribute selector cascade sort it out. The overhead is negligible — a brand token file is typically 1-3 KB uncompressed.

For dynamic multi-tenant scenarios — think 50+ clients each with custom hex codes stored in a database — you'll want to generate and inject a <style> tag at request time. Build a simple token serializer that maps a tenant's brand config object to a CSS string and injects it into the document head server-side. This is just string templating; don't over-engineer it.

// utils/buildBrandStyles.ts
interface BrandConfig {
  brandId: string;
  primaryColor: string;
  radiusMd: string;
  fontHeading: string;
}

export function buildBrandStyles(config: BrandConfig): string {
  return `
    [data-brand="${config.brandId}"] {
      --color-primary: ${config.primaryColor};
      --radius-md: ${config.radiusMd};
      --font-heading: '${config.fontHeading}', sans-serif;
    }
  `.trim();
}

// In your Next.js layout or Express middleware:
// const styles = buildBrandStyles(tenant.brandConfig);
// Inject as <style dangerouslySetInnerHTML={{ __html: styles }} />

The security note: sanitize any values you're injecting into CSS. A primaryColor value of red; } body { display: none; } [data-brand="x is a real attack surface if you're trusting user input. Validate hex codes, rem values, and font names against strict regexes before serializing.

Testing Brand Fidelity and Catching Regressions

How do you catch it when a token change breaks one of your brands? Visual regression testing is the answer, but you need to be deliberate about the test matrix. Chromatic, Percy, and similar tools let you run stories in multiple brand configurations and diff them against a baseline. Set this up early.

Write unit tests for your token serializer and brand config loader. Test that invalid hex values are rejected, that missing tokens fall back gracefully to defaults, and that the data-brand attribute gets applied and cleaned up correctly by your provider. These are fast, cheap tests that catch real bugs.

For accessibility, run your contrast checks per brand, not just once. A primary color that passes WCAG AA at 4.5:1 in the default brand might drop to 3.2:1 in a brand override. Automated contrast checking in CI is non-negotiable when you're shipping N brands — see the WCAG accessibility guide for tooling recommendations that slot into most CI setups.

Finally, document your token contracts. A brand integrator needs to know which tokens are required, which are optional, and what the fallback behavior is. A TypeScript type for BrandTokens and a generated reference page in Storybook is usually enough. It's not glamorous work, but it's what makes third-party brand contributions actually work without a back-and-forth.

FAQ

Can I use this multi-brand pattern with Tailwind CSS v4?

Yes. Tailwind v4.0.2 natively reads CSS custom properties defined in your @theme block, so tokens like --color-primary generate utility classes like bg-primary automatically. You can also use arbitrary values (bg-[var(--color-primary)]) for tokens outside the Tailwind theme scope. The data-brand attribute selector approach works perfectly alongside Tailwind's cascade.

Should I use a React context or just CSS variables for brand switching?

Both, for different jobs. CSS custom properties handle all visual differences — colors, spacing, radius, shadows — with zero JavaScript overhead and full SSR compatibility. React context is for non-visual brand differences: which icon set to use, which analytics prefix to fire, copy defaults, feature flags per brand. Don't route visual logic through context.

How do I handle SSR hydration mismatches when the brand is determined at runtime?

The cleanest approach is to resolve the brand server-side (from a subdomain, cookie, or request header) and render the data-brand attribute in your layout HTML. This means the CSS is already applied before hydration, so there's no flash of the wrong brand. If you must resolve brand client-side only, suppress the mismatch warning with suppressHydrationWarning on the root element and apply the attribute in a useLayoutEffect.

What's the performance cost of loading multiple brand token files?

Minimal for 2-10 fixed brands. A full brand token file with 30-40 custom properties typically compresses to under 800 bytes. All brand CSS loads upfront, but only the active brand's attribute selector matches, so there's no runtime style recalculation on brand switch — just a single DOM attribute change. For 50+ dynamic brands, switch to server-side style injection so you only ship the active brand's tokens.

Can two brands render side-by-side on the same page?

Yes, that's one of the strengths of the data-brand attribute approach. Apply the attribute to a container element rather than document.documentElement, and everything inside that container inherits the brand tokens via the CSS cascade. This is particularly useful for agency preview tools, A/B testing UIs, or admin dashboards where you need to show brand comparisons.

How do I manage font loading for multiple brands without FOUT?

Include <link rel="preload"> tags for the active brand's fonts in your server-rendered HTML, resolved at request time from the tenant config. For static deployments with a fixed set of brands, preload all brand fonts in the document head — the overhead is acceptable at 2-5 brands. Avoid font injection via injected <style> tags with @font-face declarations after initial render, as browsers won't preload these and you'll get visible flicker.

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

Read next

Design System Adoption: Getting Teams to Actually Use ItAccessibility-First Design Systems: WCAG 2.2 in Every ComponentTailwind + CSS Variables: Dynamic Theming Without JavaScriptBuilding a Full Design System with Tailwind in 2026