← All posts

Building a Multi-Brand Design System with Panda CSS

A practical guide to building a multi-brand design system with Panda CSS, covering brand-aware tokens, multiple themes, and how to ship it as a shared preset.

June 3, 2026Esther Adebayo
design-systemscssguides

Multi-brand design systems are a specific kind of hard. You need one component library that renders as Brand A in one context and Brand B in another, without duplicating your components, shipping separate stylesheets, or scattering brand logic throughout your component code.

This guide covers how to build a multi-brand design system with Panda CSS and shipping the whole thing as a shared preset that other teams can consume.

Multi-brand theming with Panda CSS

Why Choose Panda CSS for a Multi-Brand Design System

Panda runs at build time, generates a single plain CSS file, and handles brand switching at the CSS variable layer. Your components never change. The brand does.

Runtime CSS-in-JS evaluates styles in the browser. Adding a second brand means interpolating brand values into your style functions at runtime, which grows the JavaScript cost with every brand you add. SSR hydration gets complicated, and runtime style injection does not work in a server-only component tree.

Panda sidesteps all of this. Each theme defines its own semantic token overrides and gets compiled to a scoped [data-panda-theme] selector. Switching brands is a single attribute change. Every token value is inferred into your style props, so brand mismatches are type errors, not silent production bugs. No runtime, no hydration concerns, no per-brand stylesheets.

The Config is Your Schema

Everything in Panda flows from panda.config.ts. This file is the contract between your design decisions and your component code.

panda.config.ts

import { defineConfig } from '@pandacss/dev'
 
export default defineConfig({
  include: ['./src/**/*.{ts,tsx}'],
  outdir: 'styled-system',
  jsxFramework: 'react',
  preflight: true,
  strictTokens: true,
  theme: {
    extend: {
      semanticTokens: {}
    }
  },
  themes: {},
  staticCss: {
    themes: ['*']
  }
})

When you run panda codegen, Panda writes a styled-system/ directory into your project. That directory contains everything your components need:

styled-system/
├── css/        # css(), cva(), sva(), cx(), mergeCss()
├── recipes/    # compiled recipe functions
├── patterns/   # flex, grid, stack, container, etc.
├── jsx/        # Box, Stack, Flex, and the panda() factory
├── tokens/     # token values and CSS variable names
└── themes/     # per-theme JSON and injectTheme/getTheme utilities

This directory is generated code. Commit it. It should stay in sync with your config, and your CI should verify that.

strictTokens: true enforces that every style value must come from a token you have defined. For a shared design system this is the right default: it closes the gap between "we have a token system" and "the team actually uses it."

Setting Up Your Tokens

Define the Default Theme

Within the theme object in your config, define the default semantic tokens for your application. These are the values every component uses unless a named theme overrides them:

theme: {
  extend: {
    semanticTokens: {
      fonts: {
        cardTitle: { value: 'Inter' },
      },
      colors: {
        cardBg: { value: 'white' },
        cardAccent: { value: 'black' },
      },
    },
  },
},

Define Multiple Themes

Use the themes key to define each additional brand. Each theme accepts tokens for raw primitive values and semanticTokens for aliased values. Only specify what needs to override the default. Everything else falls back:

themes: {
  'brand-b': {
    tokens: {
      colors: {
        accent: { value: '#e53e3e' },
      },
    },
    semanticTokens: {
      fonts: {
        cardTitle: { value: 'Domine' },
      },
      colors: {
        cardBg: { value: 'white' },
        cardAccent: { value: '{colors.accent}' },
      },
    },
  },
  'brand-c': {
    tokens: {
      colors: {
        accent: { value: '#276749' },
      },
    },
    semanticTokens: {
      fonts: {
        cardTitle: { value: 'Paytone One' },
      },
      colors: {
        cardBg: { value: '{colors.green.50}' },
        cardAccent: { value: '{colors.accent}' },
      },
    },
  },
},

Panda compiles each named theme into a scoped CSS selector. Any component rendered inside an element with the matching data-panda-theme attribute picks up that theme's token values automatically.

Themes are not limited to color. Typography and shape are just as brand-defining. You define them the same way: set the defaults in theme, then override per brand in themes:

theme: {
  extend: {
    semanticTokens: {
      fonts: {
        body: { value: 'Inter' },
        heading: { value: 'Inter' },
      },
      radii: {
        pill: { value: '{radii.md}' },
      },
    },
  },
},
 
themes: {
  'brand-c': {
    semanticTokens: {
      fonts: {
        body: { value: 'EB Garamond' },
        heading: { value: 'EB Garamond' },
      },
      radii: {
        pill: { value: '{radii.full}' },
      },
    },
  },
},

A component using fontFamily: 'body' and borderRadius: 'pill' adapts its typeface and shape to the active brand without any conditional logic.

Generate Themes

Add staticCss to tell Panda to generate CSS for all defined themes:

theme: {
  // your default theme
},
themes: {
  // your named themes
},
staticCss: {
  themes: ['*'],
},

After running panda codegen, Panda generates scoped CSS for every theme. The default theme tokens apply at :root; named theme tokens are scoped to [data-panda-theme="<name>"].

Using The Themes

Where to place data-panda-theme depends on your product structure. If each deployment is a single brand, set it on <html>:

// app/layout.tsx (Next.js)
<html data-panda-theme="brand-b">

Switching Themes at Runtime

Codegen produces two utilities in styled-system/themes: getTheme and injectTheme.

getTheme(name) lazily loads the theme's CSS via a dynamic import, which keeps the initial bundle small. Only the active theme's CSS is fetched. injectTheme(el, theme) injects a scoped <style> tag into the document head and sets data-panda-theme on the target element.

import { getTheme, injectTheme } from '../styled-system/themes'
 
const handleThemeChange = async (themeName: string) => {
  const theme = await getTheme(themeName)
  injectTheme(document.documentElement, theme)
}
 
return (
  <select onChange={(e) => handleThemeChange(e.target.value)}>
    <option value="brand-b">Brand B</option>
    <option value="brand-c">Brand C</option>
  </select>
)

Every component inside the container resolves to that theme's token values. Components outside it continue using the defaults. This means multiple brands can coexist on the same page (a shared widget, a multi-tenant dashboard, a live theme preview) without any component changes.

If you are shipping a design system that other teams consume, export a typed ThemeProvider that wraps injectTheme so consumers do not need to know the implementation details:

import { getTheme, injectTheme } from '../styled-system/themes'
 
type ThemeName = 'brand-b' | 'brand-c'
 
export const ThemeProvider = ({ theme, children }: { theme: ThemeName; children: React.ReactNode }) => {
  useEffect(() => {
    getTheme(theme).then((t) => injectTheme(document.documentElement, t))
  }, [theme])
 
  return <>{children}</>
}

Get Started

Install Panda and initialize your config:

npm install -D @pandacss/dev
npx panda init --postcss

panda init writes a starter panda.config.ts, adds the PostCSS plugin, and generates your initial styled-system/ directory. From there, define your default semantic tokens, add your named themes, and run:

npx panda codegen

The full documentation is at panda-css.com/docs. The installation guides cover framework-specific setup for Next.js, Vite, Astro, Remix, and others.

Most of the difficulty with multi-brand design systems comes from putting brand logic in the wrong layer: component props, duplicated stylesheets, runtime evaluation that grows with every brand you add. Panda keeps all of that in the config, which is where it belongs. Your components stay clean. Adding a new brand is a config change, not a codebase change.