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` — whether your design system ships static CSS or build info.
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.
Two distribution models
prefix is the fix in both cases — it's the only thing that survives a
cross-version collision. What differs between setups is who owns the
prefix and what it's keyed to, and that follows from how the design
system ships its CSS:
| Distribution | Who sets prefix | Keyed to | Tradeoff |
|---|---|---|---|
| Lib ships static CSS | Lib author | DS version (e.g. acme-v1) | One DS bundle per major, shared across every remote |
| Lib ships build info + preset | Remote owner | Remote identity (e.g. remote1) | Each remote tree-shakes its own DS slice; cross-version isolation falls out for free |
In the static-CSS model the lib runs panda cssgen itself and publishes
the finished stylesheet, so the prefix has to be baked in at the lib's build
time — and the only thing it can safely key to is its own version. The
sections from Setting prefix through Recommended remote
config walk this model in detail.
In the build-info model the lib ships extraction metadata and a preset instead of CSS, and each consuming remote generates the final stylesheet during its own app build. The prefix moves to the remote's config, keyed to the remote's identity. See Model 2: Library ships build info + preset below.
If the lib and every consumer all use Panda, the build-info model is the
one the Component Library guide
already recommends — it tree-shakes the output down to the styles each app
actually uses. Federation doesn't change that recommendation; it just adds
the prefix requirement on the consumer side.
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).
Recommended remote config
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.
Model 2: Library ships build info + preset
Everything above assumes the lib publishes prebuilt CSS. The other shape is
shipping build info
and a preset: each consuming remote generates the final stylesheet during its
own build. The collision is identical, and prefix is still the fix — it
just moves to the consumer's config, keyed to the remote's identity
rather than the DS version.
panda ship only writes extraction results (which styles are used), not the
token and recipe definitions. So a build-info consumer wires up two
pieces: the build-info file (via include) and the lib's preset (via
presets). The preset supplies the definitions, the build-info file supplies
the usage.
prefix lives on the consumer's top-level config and is applied at emission,
so .button becomes .remote1-button and --colors-brand becomes
--remote1-colors-brand — regardless of whether the definition came from the
remote's own source or from the lib's preset.
remote1/panda.config.ts
import { defineConfig } from '@pandacss/dev'
import { acmeDsPreset } from '@acme-org/design-system'
export default defineConfig({
prefix: 'remote1',
presets: ['@pandacss/dev/presets', acmeDsPreset],
importMap: '@acme-org/styled-system',
include: [
'./node_modules/@acme-org/design-system/dist/panda.buildinfo.json',
'./src/**/*.{ts,tsx}'
]
})Cross-version isolation is automatic
Because the prefix is keyed to the remote — not to the DS version — version
isolation falls out without anyone tracking versions. If remote1 pins
@acme-org/design-system@29 and remote2 pins @30, each remote resolves
its own copy in node_modules, gets its own preset plus build info, and
emits CSS under its own remote prefix:
/* remote1's bundle (DS v29) */
.remote1-button { ... }
:where(:root, :host) { --remote1-colors-brand: #ea580c; }
/* remote2's bundle (DS v30) */
.remote2-button { ... }
:where(:root, :host) { --remote2-colors-brand: #2563eb; }
Two payloads can't collide on a selector or variable name, so the host page loads both without a fight.
Note: In the build-info model the lib never sets a prefix — each
consumer namespaces itself. A lib that baked in its own prefix would force
every consumer to share it, which puts back the collision you set out to
avoid.
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 ofprefixbecause they target raw element selectors. Setpreflight: falseon remotes. - Hand-written CSS.
prefixoperates 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.
Related
- 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