Styling & Theming
This project styles every component with PandaCSS (type-safe, zero-runtime CSS-in-JS) on top of raw hono/jsx — not one of Panda's officially supported JSX frameworks (React/Vue/Solid/Qwik). That one fact — jsxFramework: undefined in panda.config.ts — is the root cause of nearly every styling regression this project has hit, because it silently opts the codebase out of several things Panda normally does for you. This page documents the resulting architecture, the concrete bugs it has already caused, and the checklist to follow so they don't recur.
How styles get from a recipe to the page
- Recipes are authored under
app/theme/recipes/*.tswith Panda'sdefineRecipe(single class, e.g.badge.ts) ordefineSlotRecipe(multi-part components with named slots, e.g.switch.ts—root/control/thumb/…). - Every recipe is registered by object key in
app/theme/recipes/index.ts, underrecipes(flat) orslotRecipes(slotted). The registration key is what everything else refers to it by — not the recipe'sclassName, and not its file name.switch.tsexportsswitchRecipe(switchis a reserved word) and is registered asswitchRecipe: switchRecipe;panda.config.tsand any code that references the recipe must useswitchRecipe, neverswitch. panda.config.tsextendsapp/theme/'s combined config (theme: { extend: { ...theme.config } }) and scansinclude: ["./app/**/*.{js,jsx,ts,tsx}"]for style usage.- Panda writes the generated system — recipe helper functions, tokens, patterns, the
css/cxruntime — intodesign-system/, imported via thedesign-systemVite alias (vite.config.ts).design-system/is gitignored — it's a build artifact, regenerated fresh on every install/build via PostCSS (postcss.config.cjswires@pandacss/dev/postcss) and thepreparescript (panda codegen, run once onbun install). Only the source files —app/theme/recipes/*.ts,panda.config.ts— are what actually ship; nothing you do todesign-system/directly is ever committed.
codegen vs cssgen — you often need both, by hand
| Command | Regenerates | Needed after |
|---|---|---|
bunx panda codegen |
design-system/recipes/*.mjs + .d.ts — the recipe helper functions (switchRecipe(), .splitVariantProps(), .variantKeys, .variantMap) that application code calls at runtime |
Adding/removing/renaming a variant on any recipe |
bunx panda cssgen |
design-system/styles.css — the actual generated CSS rules |
Adding/removing a variant value, or changing staticCss |
vite dev's PostCSS integration re-extracts CSS on the fly as you edit, but it does not regenerate the recipe helper .mjs/.d.ts files — those are plain generated files your code imports directly (import { switchRecipe } from "design-system/recipes"), not a Vite-transformed virtual module. If you add a colorPalette prop to a recipe's variants and only save the file, switchRecipe.splitVariantProps() keeps using the stale variantKeys list until you run panda codegen — so the new prop gets silently routed into "local props" instead of "variant props", and either leaks onto the DOM as an invalid attribute or never reaches the style function at all. Run both commands by hand after editing any recipe file, don't assume the dev server picked it up.
staticCss: why almost every recipe is forced to ["*"]
Panda's static analysis can only pre-generate CSS for values it can see literally in source (<Badge size="sm">) — or, for JSX-framework integrations, via each recipe's jsx: [...] mapping (button.ts and a handful of others use this). This codebase's components are called as recipeName(variantProps) inside primitive files, always with a runtime-computed object (switchRecipe.splitVariantProps(props)), and most content is CMS-authored JSON (content/pages/*.json) with color/size/variant values that don't exist anywhere as a string literal in .tsx source at all. Panda's extractor cannot see any of this, so every recipe without a jsx mapping must be forced to generate all its variant combinations via panda.config.ts's staticCss.recipes: { <recipeKey>: ["*"] } — using the registration key from app/theme/recipes/index.ts, not the recipe's className.
This is exactly how the switch-size bug (below) happened, and it's a systemic hazard: any typo between staticCss.recipes's key and the recipe's actual export/registration name silently drops that recipe's generation to zero non-default variants, with no error, warning, or type failure anywhere — Panda just quietly emits base + default-variant CSS and nothing else. If you add a brand-new recipe, remember to add its registration-key entry to staticCss.recipes immediately, and double check the key matches app/theme/recipes/index.ts exactly (grep for the key in both files if unsure).
Known regressions and the rules that prevent them
1. staticCss.recipes key must match the recipe's registration name, not its className
Symptom: every non-default variant value (e.g. size="sm"/"lg" on <Switch>) renders with the CSS custom properties simply unset — a collapsed, invisible 0×0 element, or missing styling entirely — while the default variant looks fine.
Cause: switch.ts exports switchRecipe (registered under that key in slotRecipes), but panda.config.ts's staticCss.recipes had switch: ["*"] — a key that matches nothing. Only base + defaultVariants CSS ships (Panda always emits those regardless of staticCss); every other variant value silently gets zero generated CSS.
Fix: the key in staticCss.recipes must be the exact registration key from app/theme/recipes/index.ts (switchRecipe, not switch). This is easy to get wrong for any recipe whose file/className don't match its export name, and gives zero compile-time or runtime signal when wrong — verify by grepping the generated CSS for the variant class you expect (e.g. grep "switch__root--size_sm" design-system/styles.css) after any staticCss edit, don't just eyeball the config.
This recurred three times on the same recipe before actually landing, because design-system/ is gitignored — running cssgen after fixing the config produces correct local output, but if the panda.config.ts edit itself is never committed, the bug returns the moment someone else regenerates. The only durable part of any staticCss/recipe fix is the source file edit (panda.config.ts, app/theme/recipes/*.ts) — always confirm with git status/git diff that those files are actually staged, not just that the local CSS looks right.
2. Slot-recipe variants must be slot-keyed, or Panda silently drops them
Symptom: a variant class is present in the rendered HTML (carousel__root--colorPalette_green), the intended style is completely absent, and the generated CSS has zero rules for that class at all — not even an empty one.
Cause: in a defineSlotRecipe, every variant value must nest its styles under the slot name it targets:
// WRONG — silently produces no CSS at all in a slot recipe
variants: {
colorPalette: {
blue: { colorPalette: "blue" },
},
},
// RIGHT — slot-keyed
variants: {
colorPalette: {
blue: { root: { colorPalette: "blue" } },
},
},
A flat defineRecipe (no slots — badge.ts, anchor.ts, button.ts) uses the unslotted form correctly; copying that pattern into a defineSlotRecipe (anything with a slots: [...] array) is the mistake. This has bitten rating-group.ts, carousel.ts, and clipboard.ts independently — it's an easy copy-paste trap between the two recipe kinds, and worth a second look any time a slot recipe's new variant "does nothing."
3. base conditional styles lose to variant styles for the same CSS property — always, regardless of specificity
Symptom: a conditional/state-driven style placed in a slot recipe's base block (e.g. &[data-complete]: { borderColor: ... }) never applies, even though the attribute/class is confirmed present in the DOM and the CSS rule itself is confirmed present in the stylesheet.
Cause: Panda emits base styles and variants styles into separate CSS cascade layers, with the variants layer ordered after base. CSS layer order beats selector specificity unconditionally — a highly-specific base selector still loses to a bare unconditional rule in the variants layer if they set the same property.
Fix: move the conditional override into every relevant variant.<name>.<slot> block instead of base, spreading a shared object into each if the styles repeat. app/theme/recipes/input.ts's _invalid handling (duplicated per-variant, not centralized in base) is the existing reference pattern.
4. colorPalette.* tokens resolve to a real (gray-ish, near-black) color, never "no color" — an active scope is required
Symptom: a component styled with bg: "colorPalette.solid.bg" etc. renders in a dark, muted gray/black regardless of what colorPalette prop was passed (or even if the recipe has no colorPalette concept at all).
Cause: colorPalette.* are virtual tokens that resolve against whatever --colors-color-palette-* custom properties are in scope at that DOM node. The theme sets a global default scope to gray at :root/html, so anywhere no more specific colorPalette scope is active, colorPalette.solid.bg silently resolves to gray.solid.bg — not transparent, not an error, a real (dark) color. This is easy to misdiagnose as "broken" when it's actually just "unscoped."
Fix: apply an actual colorPalette scope, per the centralized pattern below — don't assume a component "just doesn't have color" because it looks gray/black.
5. Responsive variant props don't emit breakpoint CSS
<Heading size={{ base: "2xl", md: "3xl" }}> renders class="heading--size_2xl md:heading--size_3xl" correctly in HTML, but no @media rule for the md: class ever gets generated — staticCss.recipes: ["*"] only force-generates literal, non-responsive variant classes, it doesn't cross-product them with breakpoint conditions. Never pass a responsive object as a variant prop; use a flat literal value, or a wrapping css()/utility-class override for the specific property that needs to change per breakpoint (plain utility classes don't have this limitation, only recipe variant props do).
colorPalette theming: the centralized pattern
Panda's officially-supported JSX frameworks get colorPalette "for free": their styled() wrapper auto-splits a colorPalette prop off any component and merges it in as a generic utility class, alongside whatever the recipe's own variants produce. This repo doesn't get that, because jsxFramework: undefined. Upstream Park UI recipes (this project's original component source, ported by hand to hono/jsx) rely on exactly that missing integration — their own switch.ts has no colorPalette variant either.
For years for a subset of components, this was patched around by hand-declaring a colorPalette variant on the recipe itself (badge.ts, anchor.ts, button.ts, …), copy-pasting the same ~11-entry palette map into each one. That approach is what caused most of the bugs above: it's easy to forget on a new component (switch.ts/avatar.ts/card.ts/checkbox.ts never got it, so their colorPalette prop silently did nothing), easy to get the slotted-vs-unslotted form wrong (§2), and it scattered the same palette-name list — with drift — across a dozen files (some recipes' hand-rolled maps referenced teal/indigo/pink/yellow, palette names that don't even exist in this theme's app/theme/colors/ — those would have silently done nothing too).
The current pattern (as of the colorPalette centralization pass) replaces all of that with one shared utility:
panda.config.ts'sstaticCss.cssforce-generates the plain PandacolorPaletteutility class (.color-palette_blue, etc.) for every real palette name the theme actually defines (gray/blue/green/red/orange/purple/cyan/amber— checkapp/theme/colors/*.tsfor the current list before adding a new name anywhere).app/components/ui/color-palette.tsexportscolorPaletteClass(colorPalette?: string), which resolves aliases (success→green,error→red,warning→orange,slate→gray —slatein particular is used throughout CMS content even though this theme's gray scale is only ever registered asgray) and returns the right utility class.
To add colorPalette support to a component (a component whose recipe references colorPalette.* tokens but has no way for a consumer to actually pick one):
- Do not add a
colorPalettevariant to the recipe. Leave the recipe'scolorPalette.*token references as-is. - In the component's primitive (
*-primitive.tsx), destructurecolorPaletteout of the props before whatever...rest/...restPropsspread lands on the DOM node — same as any other known prop — so it never leaks as an invalidcolorpalette="..."HTML attribute. - If the component previously had a hardcoded default color, preserve it as a plain JS default on the destructure (
colorPalette = "blue"), not a recipedefaultVariantsentry. - Merge
colorPaletteClass(colorPalette)into the root/primary slot's class list —cx(styles.root, colorPaletteClass(colorPalette), classProp)— so it cascades to descendant slots via normal CSS custom-property inheritance. It only needs to go on the outermost element that establishes the scope, never on every slot. - If the component is composed via a context provider (e.g.
ButtonGroup→ButtonviaButtonContext), make surecolorPaletteis threaded through that context value explicitly — it's no longer part ofvariantProps, so anything that used to forwardvariantPropsalone to propagate color needscolorPaletteadded back in by hand. - Regenerate (
panda codegen && panda cssgen) and verify: render the component, grep the SSR HTML (ordesign-system/styles.css) for the expectedcolor-palette_<name>class, and confirm no barecolorpalette=attribute leaked onto the DOM.
Don't reintroduce a per-recipe colorPalette variant map for a new component — that's the exact pattern this centralization replaced, specifically because it doesn't scale and silently regresses.
Token colors vs. semantic tokens
tokens.colors(app/theme/tokens/colors.ts) — pure static values (black, white).semanticTokens.colors(app/theme/index.ts,app/theme/colors/*.ts) — the adaptive palette scales (gray/slate, blue, red, green, orange, purple, cyan, amber), each compiled into automatic light/dark custom properties.
Avoid generic bg/fg tokens — they compile to transparent/invalid CSS in this theme (the "Remove Panda Preset Colors" plugin in panda.config.ts strips Panda's default color preset, and generic bg/fg were never replaced). Use explicit semantic tokens instead: gray.surface.bg, fg.default, gray.outline.border, etc. Popups/dropdowns/autocomplete panels specifically should use gray.surface.bg (not colorPalette.surface.bg) so the panel background stays a neutral opaque surface regardless of whatever accent color is active on the trigger.
Where the site-wide default accent actually lives
Every color file under app/theme/colors/*.ts (amber, blue, cyan, green, orange, purple, red, slate) is a fully-formed, ready-to-use colorPalette option — identical shape (a 1–12 scale, an a1–a12 alpha scale, and solid/subtle/surface/outline/plain sub-groups, each with bg/fg). A color existing in that directory only means it was generated (e.g. by the Park UI CLI at project init) — it says nothing about whether it's active anywhere.
Nothing in Panda itself, and nothing in Park UI's CLI output (components.json), persists which color you pick as "the" accent during init — there's no config key for it in either. The only thing that actually decides the site-wide default is one hand-written line:
// app/theme/global-css.ts
html: {
colorPalette: "gray",
// ...
},
This sets the root colorPalette scope every unscoped element inherits. It's a plain CSS declaration, easy to overlook, and easy to lose track of — if you ever wonder "why does this project use gray as its accent instead of cyan/whatever I picked at init," this line is the answer, and changing it is a one-line, low-risk edit (every color file has the identical token shape, so swapping the value is a clean drop-in).
This default is a fallback only. Any of the 13 components migrated to the centralized colorPaletteClass() pattern above (switch, badge, button, card, …) carry their own explicit default (see the per-component list in that section) and will not follow a change to this line — only components/utilities with no explicit colorPalette of their own (the global focus-ring/selection custom properties also declared in global-css.ts, and any not-yet-migrated recipe) actually read this root value.
fg/border/canvas are deliberately gray-only, always
fg.default, fg.muted, fg.subtle, border, and canvas (declared in app/theme/index.ts's semanticTokens.colors) are hardcoded directly to colors.gray.* — not colors.colorPalette.*. This is intentional, not a gap: it's the standard Radix/Park UI "one accent + one gray" convention — body text, borders, and page background must stay neutral regardless of which accent is active, both for contrast/readability and so the accent reads as an accent rather than tinting the whole page. Don't "fix" these to reference colorPalette.* expecting them to pick up the site accent; that would be a regression, not an improvement. Only genuinely interactive/branded surfaces (a control's checked state, a button's solid fill, a badge's background, …) should reference colorPalette.* — confirmed as the actual pattern in every control recipe (switch.ts, checkbox.ts, badge.ts, button.ts: the neutral/unchecked state uses a hardcoded gray.* token, the checked/accent state uses colorPalette.*).
Verification checklist after any recipe or panda.config.ts change
Don't trust a visual glance — the bugs above all looked like nothing was wrong until inspected closely (a black/gray element reads as "default styling," not "broken").
bunx panda codegen && bunx panda cssgen(from the repo root — checkpwdfirst; running these from a subdirectory silently writesdesign-system/into the wrong nested location instead of erroring).- Grep the generated CSS for the specific class you expect:
grep "switch__root--size_sm" design-system/styles.css,grep "color-palette_blue" design-system/styles.css. - Render the actual page (dev server or
bun test) and grep the SSR HTML output for the same class, and for the absence of any leaked raw prop name as a DOM attribute (colorpalette="...",size="..."etc. on an element that shouldn't have it). bun test— run the full unit suite; a stale test asserting an old classname format is a common false-positive after a styling refactor, worth a quick read before assuming a real regression.git status/git diffthe source files (panda.config.ts,app/theme/recipes/*.ts,app/components/ui/*.tsx) — neverdesign-system/, which is gitignored and gets discarded. A fix that "works locally" but was never actually committed in the source is not a fix.
FAQ
How can I change the default site-wide accent color?
Edit one line in app/theme/global-css.ts:
html: {
colorPalette: "cyan", // was "gray"
colorScheme: { _light: "light", _dark: "dark" },
},
The value must be one of the palette names that actually exist under app/theme/colors/*.ts (amber, blue, cyan, green, orange, purple, red, slate — registered as gray). Every one of those files has the identical token shape (solid/subtle/surface/outline/plain, each with bg/fg), so this is a clean, safe swap — no other token needs to change alongside it.
Then:
bunx panda codegen && bunx panda cssgento regenerate.- Check the result:
fg/border/canvas(body text, dividers, page background) will not change — they're deliberately hardcoded to the neutral gray scale regardless of accent, see above. Only genuinely accent-driven surfaces (a checked switch/checkbox, a solid button, an unscoped badge, focus rings, text selection) will pick up the new color. - Remember this only changes the fallback. Any of the 13 components already migrated to
colorPaletteClass()(switch, badge, button, anchor, card, checkbox, carousel, clipboard, date-picker, radio-card-group, rating-group) carry their own explicit per-component default and need each one updated by hand in its primitive (app/components/ui/*-primitive.tsx, thecolorPalette = "..."destructure default) if you want them to follow the new site accent too, rather than keep their current individually-chosen default.