guides
federated microfrontends

Federated Micro-Frontends

How to safely run multiple Panda-built applications (and multiple versions of the same design system) on a single host page using `prefix` and `cssVarRoot`.

Panda supports federated micro-frontend architectures — multiple independently-built apps loaded onto a single host page — through the prefix and cssVarRoot config options.

This guide shows how to set up a Panda lib so it can co-exist with other Panda libs (or with a different version of itself) without class-name or CSS variable collisions.

When you need this

Any setup that loads two or more independently-built Panda stylesheets into one document is affected:

  • Webpack Module Federation
  • single-spa, qiankun, or other micro-frontend orchestrators
  • Dynamically-injected widgets (Intercom-style embeds)
  • A host page that pins one version of your design system while a remote pins a different version

If your bundles never share a document — for example with Next.js multi-zone routing or iframe embedding — you do not need this guide. Each app's CSS loads in its own document and the cascade cannot cross.

The two classes of clash

There are two distinct ways federated bundles collide.

Override leakage (single version)

Two remotes load the same DS version, and one of them defines a local override:

/* Remote 1 */
.ds-background { background-color: blue; }
.remote1-background { background-color: red; }
 
/* Remote 2 */
.ds-background { background-color: blue; }

Load order decides whether .remote1-background wins. Wrap your DS rules in a cascade layer:

panda.config.ts

import { defineConfig } from '@pandacss/dev'
 
export default defineConfig({
  layers: {
    recipes: 'ds.recipes',
    utilities: 'ds.utilities'
  }
})

Unlayered styles always beat layered styles per the CSS cascade spec, so the remote's local overrides are now deterministic regardless of load order.

Cross-version collision

Remote A pins @acme/ds@1, Remote B pins @acme/ds@2. Both bundles emit a .button rule from defineRecipe, and both emit --colors-brand on :root — with different values:

/* v1's bundle */
:root { --colors-brand: #ea580c; }
.button { background: var(--colors-brand); padding: 8px 12px; }
 
/* v2's bundle */
:root { --colors-brand: #2563eb; }
.button { background: var(--colors-brand); padding: 20px 40px; }

Same selector, different rule. Same custom property, different value. The cascade picks one globally, and the loser is silently overwritten. Cascade layers do not help here — both versions emit into the same recipes layer, and within a single layer load order still decides.

This is the case prefix is designed for.

Setting prefix

Set a distinct prefix per design-system version. Panda prepends it to every emitted class name and CSS variable.

panda.config.ts

import { defineConfig } from '@pandacss/dev'
 
export default defineConfig({
  prefix: 'acme-v1',
  preflight: false,
  theme: {
    extend: {
      tokens: {
        colors: {
          brand: { value: '#ea580c' }
        }
      },
      recipes: {
        button: {
          className: 'button',
          base: {
            background: 'brand',
            padding: '8px 12px'
          }
        }
      }
    }
  }
})

After panda cssgen, the bundle emits:

:where(:root, :host) {
  --acme-v1-colors-brand: #ea580c;
}
 
.acme-v1-button {
  background: var(--acme-v1-colors-brand);
  padding: 8px 12px;
}

A second version with prefix: 'acme-v2' emits .acme-v2-button and --acme-v2-colors-brand. Both bundles can sit in the same <head> without clashing — the cascade has nothing to fight over.

💡

Note: Source code in the lib and consumer apps doesn't change. Panda's runtime css(), cva(), and recipe helpers read prefix from config and emit the namespaced class names automatically.

Deriving the prefix from your package

Hardcoding the prefix is brittle. The robust pattern is to derive it from your package's major version:

panda.config.ts

import { defineConfig } from '@pandacss/dev'
import pkg from './package.json' assert { type: 'json' }
 
const major = pkg.version.split('.')[0]
const slug = pkg.name.replace(/[@/]/g, '-').replace(/^-/, '')
 
export default defineConfig({
  prefix: `${slug}-v${major}`,
  // ...
})

For @acme/lib@1.4.2, this produces acme-lib-v1. Bumping to a 2.x release automatically rotates the prefix to acme-lib-v2, so two majors loaded into the same host page are structurally isolated without any human intervention.

💡

Note: Patch and minor releases share a prefix. If you need every release to be structurally distinct, use the full version (acme-lib-v1-4-2). Major-only is the recommended default — it matches the granularity humans already reason about for breaking changes.

Scoping tokens with cssVarRoot

prefix namespaces the CSS variable names. That alone is enough to prevent the :root collision. If you also want the variables to be scoped to a subtree — so tokens declared by one remote don't apply to elements outside its mount point — set cssVarRoot:

panda.config.ts

export default defineConfig({
  prefix: 'acme-v1',
  cssVarRoot: '.acme-v1-scope'
})

Emitted CSS:

.acme-v1-scope {
  --acme-v1-colors-brand: #ea580c;
}

The remote then wraps its mount point in an element that carries the class:

<div className="acme-v1-scope">
  <Button>Click</Button>
</div>

Elements outside .acme-v1-scope won't resolve --acme-v1-colors-brand. Useful when multiple remotes share a host page but each remote's tokens should be contained to its own DOM subtree.

cssVarRoot is optional. prefix alone is sufficient for most federated setups because the variable names already differ per version.

How defineRecipe and cva are affected

Both recipe APIs benefit from prefix, in slightly different ways.

defineRecipe emits a stable class name based on the recipe name (.button, .button--size-lg). Without prefix, two versions of the same recipe produce identical class names with different declarations. With prefix, each version emits its own namespaced class — acme-v1-button, acme-v2-button — and the collision is gone.

cva with raw values is already collision-free:

src/Button.tsx

import { cva } from '../styled-system/css'
 
const button = cva({
  base: {
    background: '#ea580c',
    padding: '8px 12px'
  }
})
// emits .bg_\#ea580c .p_8px_12px ...

The atomic class names encode the value, so two versions with different values naturally produce different class names. prefix adds a second layer of isolation but isn't strictly required here.

cva with semantic tokens is the quiet case:

const button = cva({
  base: { background: 'brand' }
})
// emits .bg_brand { background: var(--colors-brand) }

The atomic class name is stable across versions (bg_brand in both, with identical rule body), so there's no class-name clash. But both versions declare --colors-brand on :root with different values — the cascade picks one globally, and the visual result is wrong even though the class names matched. prefix fixes this by namespacing both the class (acme-v1-bg_brand) and the variable (--acme-v1-colors-brand).

For a Panda lib intended to ship into a federated host:

panda.config.ts

import { defineConfig } from '@pandacss/dev'
import pkg from './package.json' assert { type: 'json' }
 
const major = pkg.version.split('.')[0]
const slug = pkg.name.replace(/[@/]/g, '-').replace(/^-/, '')
 
export default defineConfig({
  prefix: `${slug}-v${major}`,
  preflight: false,
  jsxFramework: 'react',
  outdir: 'styled-system',
  theme: {
    extend: {
      // tokens, recipes, etc.
    }
  }
})
💡

Note: preflight: false is recommended for libs and remotes. A reset stylesheet shipped from a remote can stomp on the host's body styles. Let the host own the reset.

Verifying the output

After running panda cssgen, grep the emitted bundle for the names that previously collided:

grep -E "^\.button|--colors-brand:" styled-system/styles.css

You should see every match carrying your prefix:

.acme-v1-button { ... }
:where(:root, :host) { ... --acme-v1-colors-brand: ...; ... }

If you see unprefixed matches, double-check that prefix is set at the top level of defineConfig (not nested inside theme), and regenerate the bundle.

Limits of prefix

prefix solves the structural class-name and CSS-variable collisions for multi-version federated setups. It does not solve:

  • Same-prefix collisions. Two libs that both choose prefix: 'design' collide as if neither had a prefix. The prefix string is your isolation key — choose it like you'd choose a package name.
  • Reset collisions. If multiple remotes ship preflight: true, the resets fight regardless of prefix because they target raw element selectors. Set preflight: false on remotes.
  • Hand-written CSS. prefix operates on what Panda emits. CSS authored outside Panda still uses your hand-written selectors.

For total style isolation against an untrusted host, consider wrapping the remote in an iframe or Shadow DOM. Heavier-weight, but bulletproof.

  • Cascade Layers — solves single-version override leakage
  • Hashing — alternative to prefix when you want short opaque class names instead of namespace strings
  • Component Library — distribution patterns for shipping a Panda lib
  • Presets — sharing tokens and recipes across consumers