Multiple Themes
Ship several complete looks, each with light and dark, from one set of tokens.
A product rarely ships with one look. Dark mode comes first, then a customer's brand, a high-contrast variant, a seasonal skin. Each is the same request: the same components, different values.
Components read roles like bg: 'surface'; a theme is what those roles resolve to, and a
mode is the shade inside it. One attribute picks the theme, one class picks the mode, and no component knows either
exists.
Let's build two themes on a neutral default, each with its own light and dark mode:
matcha: green accent, rounder controls. Two tokens change.gothic: purple accent on a darker canvas, square corners, serif type. Four tokens change.
Then ship them: all in one stylesheet, or loaded on demand.
Semantic tokens
Name tokens after their role, and give every value both modes. With the base preset, _dark means "inside an element
with the dark class".
panda.config.ts
semanticTokens: {
colors: {
canvas: { value: { base: '{colors.gray.50}', _dark: '{colors.gray.900}' } },
surface: { value: { base: '{colors.white}', _dark: '{colors.gray.800}' } },
fg: { value: { base: '{colors.gray.900}', _dark: '{colors.gray.50}' } },
accent: { value: { base: '{colors.blue.600}', _dark: '{colors.blue.300}' } }
},
radii: { control: { value: '{radii.sm}' } },
fonts: { body: { value: '{fonts.sans}' } }
}<div className={css({ bg: 'surface', color: 'fg', borderRadius: 'control', fontFamily: 'body' })}>
<button className={css({ bg: 'accent' })}>Buy</button>
</div><html class="dark"></html>Toggle the class and the card flips. Nothing in the component knows about dark mode.
Themes
themes holds alternative token values behind one attribute. Override only what differs from the default.
panda.config.ts
themes: {
matcha: {
semanticTokens: {
colors: { accent: { value: { base: '{colors.green.700}', _dark: '{colors.green.300}' } } },
radii: { control: { value: '{radii.xl}' } }
}
},
gothic: {
semanticTokens: {
colors: {
canvas: { value: { base: '{colors.gray.100}', _dark: '{colors.black}' } },
accent: { value: { base: '{colors.purple.700}', _dark: '{colors.purple.300}' } }
},
radii: { control: { value: '{radii.none}' } },
fonts: { body: { value: '{fonts.serif}' } }
}
}
}<html data-panda-theme="gothic"></html>Same card, same button: now purple, square corners, serif type. Swap the attribute to matcha for green and round.
Ship the CSS
Panda emits nothing for themes until you say how to ship them. Pick one.
In the main stylesheet
List the themes in staticCss.themes. Each becomes a block of variables in the tokens layer, holding only what it
overrides.
panda.config.ts
staticCss: {
themes: ['matcha', 'gothic']
}@layer tokens {
:where(:root, :host) {
--colors-canvas: var(--colors-gray-50);
--colors-accent: var(--colors-blue-600);
--fonts-body: var(--fonts-sans);
--radii-control: var(--radii-sm);
}
[data-panda-theme='gothic'] {
--colors-canvas: var(--colors-gray-100);
--colors-accent: var(--colors-purple-700);
--fonts-body: var(--fonts-serif);
--radii-control: var(--radii-none);
}
[data-panda-theme='matcha'] {
--colors-accent: var(--colors-green-700);
--radii-control: var(--radii-xl);
}
}No runtime, one request. You pay for every theme on every page.
On demand
The generated styled-system/themes module has every theme as its own JSON file, fetched only when asked for.
import { getTheme, injectTheme } from '../styled-system/themes'
const theme = await getTheme('gothic')
// ^? { name: 'gothic'; id: string; css: string }
injectTheme(document.documentElement, theme)injectTheme sets data-panda-theme on the element and appends a <style> with the theme's variables. It returns that
style element.
Choose this when you have many themes or the user picks one at runtime. The two options are independent: the module always exists, the static CSS only includes what you list.
Theme attribute
A theme declares its values on the element that carries the attribute, and everything inside inherits them.
The whole page. Put it on <html>.
<html data-panda-theme="gothic">
<body>
…
</body>
</html>One subtree. Put it on any element: a sidebar, a preview, an embedded widget. Only that subtree changes.
<html>
<body>
<aside data-panda-theme="matcha">…</aside>
</body>
</html>Nested themes. The nearest one wins, in either direction.
<html data-panda-theme="gothic">
<body>
<aside data-panda-theme="matcha">…</aside>
</body>
</html>With dark mode. class="dark" can sit on the same element, above the theme, or inside it. Both compose.
<html class="dark" data-panda-theme="gothic">
<body>
<aside data-panda-theme="matcha">…</aside>
</body>
</html>.dark [data-panda-theme='gothic'],
[data-panda-theme='gothic'].dark,
[data-panda-theme='gothic'] .dark {
--colors-canvas: var(--colors-black);
--colors-accent: var(--colors-purple-300);
}Here the page is dark gothic and the aside is dark matcha: the theme picked the palette, dark mode picked the shade.
Tokens a theme doesn't touch, like surface, keep the default's light and dark values.
Theme condition
Every theme also gets a condition, _theme plus the capitalized name. Use it when a theme needs a structural change
rather than a different token value.
const title = css({
fontFamily: 'body',
_themeGothic: {
textTransform: 'uppercase'
}
})The condition matches the theme root and everything inside it:
.themeGothic\:text-transform_uppercase:where([data-panda-theme='gothic'], [data-panda-theme='gothic'] *) {
text-transform: uppercase;
}Reach for tokens first. A theme that is only token overrides stays swappable, and components stay unaware of it.
Server rendering
Read the choice on the server, inline the theme CSS, and set the attribute before the first paint.
app/layout.tsx
import { cookies } from 'next/headers'
import { getTheme, type ThemeName } from '../styled-system/themes'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const name = cookies().get('theme')?.value as ThemeName | undefined
const theme = name ? await getTheme(name) : undefined
return (
<html lang="en" data-panda-theme={name}>
<head>{theme && <style id={theme.id} dangerouslySetInnerHTML={{ __html: theme.css }} />}</head>
<body>{children}</body>
</html>
)
}On the client, switching is the same two calls plus storing the choice.
const next = current === 'gothic' ? 'matcha' : 'gothic'
injectTheme(document.documentElement, await getTheme(next))
document.cookie = `theme=${next}; path=/`Theme contract
A theme that forgets a token silently falls back to the default. defineThemeContract turns that into a type error.
panda.config.ts
import { defineConfig, defineThemeContract } from '@pandacss/dev'
const defineTheme = defineThemeContract({
semanticTokens: { colors: { accent: { value: '' } } }
})
export default defineConfig({
themes: {
matcha: defineTheme({ semanticTokens: { colors: { accent: { value: '{colors.green.700}' } } } }),
gothic: defineTheme({ semanticTokens: { colors: {} } })
// ^ Property 'accent' is missing
}
})