Want to skip the docs? Check out pandamastery.com - the best way to learn Panda CSS

design systems
wrap headless ui

Wrap headless UI

Use createSlotRecipeContext to turn a headless UI library like Ark UI into styled, distributable components.

Most component libraries aren't built from raw DOM elements, they wrap a headless UI library like Ark UI (opens in a new tab) or Radix UI for behavior and accessibility, then add styling on top. createSlotRecipeContext is the tool for that: it's covered in full at JSX Style Context, this page is about applying it when the components you're wrapping and shipping aren't your own.

Why not just style each part

A compound component like Accordion has a root, several items, triggers, and panels, all sharing one slot recipe. Styling each part with a plain css() call works, but every user would need to know which slot recipe variant applies to which part, and repeat that wiring themselves. createSlotRecipeContext does that wiring once, in your library, and hands the user a set of already-styled components.

Wrapping a headless root

Most headless libraries expose a Root that renders an element, and pieces like Trigger or Content that read from its context. Match withProvider and withContext to that shape:

// components/ui/accordion.tsx
import { Accordion as ArkAccordion } from '@ark-ui/react/accordion'
import { sva } from '../styled-system/css'
import { createSlotRecipeContext } from '../styled-system/jsx'
 
const accordion = sva({
  slots: ['root', 'item', 'itemTrigger', 'itemContent'],
  base: {
    root: { display: 'flex', flexDirection: 'column', gap: '2' },
    itemTrigger: { fontWeight: 'medium', cursor: 'pointer' }
  }
})
 
const { withProvider, withContext } = createSlotRecipeContext(accordion)
 
export const Accordion = {
  Root: withProvider(ArkAccordion.Root, 'root'),
  Item: withContext(ArkAccordion.Item, 'item'),
  ItemTrigger: withContext(ArkAccordion.ItemTrigger, 'itemTrigger'),
  ItemContent: withContext(ArkAccordion.ItemContent, 'itemContent')
}

If the headless library's root doesn't render a DOM element itself (some context-only providers don't), use withRootProvider instead of withProvider, it skips the slot styling and only sets up the context. See withRootProvider for that case.

Naming for config recipes

If your slot recipe is defined in panda.config.ts instead of inline sva, the exported component's name has to match the recipe name exactly, or set the recipe's jsx option to your component's name. This matters more in a library than in an app, since users see the exported name, not your internal recipe key. See Config Recipes for the exact rule.

Let users opt out

Every component built with createSlotRecipeContext accepts an unstyled prop, on the root to strip all slot styles, or on a single slot to strip just that one. Document this for your users explicitly. It's often the only supported way to fully override a slot's structure without fighting your recipe's specificity, and library authors sometimes forget to mention it exists. See unstyled prop for the exact behavior on root vs. child components.

When you need the variant value inside the component

withProvider uses variant props to pick which slot styles apply, but doesn't pass them through to your wrapped component by default. If your wrapper needs the value too, for example to mirror orientation as an aria-orientation attribute, list it in forwardProps. See Forwarding props for the full pattern, including the withContext case where a prop shares a name with a CSS property.

See also

  • forwardProps & the styled factory covers the equivalent decision for single-element wrappers built with styled(), not multi-part sva compounds.
  • Troubleshooting covers the type-export errors that show up most often when a library built this way is compiled with tsup or a bundler's declaration-file generator.