Adding a component
A component is done when it exists in Figma, in code, in Storybook, in the tests and on this
site. This page walks through the steps using a made-up Stat component: a label with a large
value.
Work in the library repository, on Node 24 with pnpm:
nvm usepnpm installpnpm devpnpm dev generates the tokens, icons and Panda output, then starts Storybook on port 6006.
The files
Section titled “The files”Directorysrc
Directorycomponents
Directorystat
- stat.recipe.ts styles
- stat.tsx component and prop types
- stat.stories.tsx Storybook stories
- index.ts public exports
- display.test.tsx tests (grouped files)
Directorypreset
- index.ts register the recipe here
- index.ts export the component here
Directoryfigma
- library.json ledger of the Figma library
Directorydocs
Directorysrc
Directoryexamples
Directorystat/ live examples
- …
Directorycontent
Directorydocs
Directorycomponents/ the page
- …
-
Start in Figma.
The component is designed in the Sulmu UI library file first. Name the variant properties the way the props will be named, with the same values:
size=sm,tone=green. Use variables for every colour, size and radius. If a value has no variable, add a token first (see The tokens workflow). -
Write the recipe.
The recipe is the component’s style: a base and its variants, written with token names. Use
defineRecipefor a component with one element anddefineSlotRecipefor one with several parts.src/components/stat/stat.recipe.ts import { defineSlotRecipe } from '@pandacss/dev'export const statRecipe: ReturnType<typeof defineSlotRecipe> = defineSlotRecipe({className: 'stat',description: 'A label with a large value.',slots: ['root', 'label', 'value'],base: {root: { display: 'flex', flexDirection: 'column', gap: '1', fontFamily: 'sans' },label: { textStyle: 'caption', color: 'fg.subtle', margin: '0' },value: { textStyle: 'heading4', color: 'fg.default', margin: '0' },},variants: {size: {sm: { value: { textStyle: 'heading6' } },md: {},},},defaultVariants: { size: 'md' },})Rules for recipes:
- Only semantic tokens:
fg.subtle, notgrey.600; spacing step'1', not'4px'. - Set
margin: '0'on headings, paragraphs and lists. There is no reset to do it. - Set
fontFamily: 'sans'on the root. The library does not stylebody. classNamebecomes the class:statgives.su-stat.
- Only semantic tokens:
-
Register the recipe in the preset.
Add it to
src/preset/index.ts: underrecipesfordefineRecipe, underslotRecipesfordefineSlotRecipe.src/preset/index.ts import { statRecipe } from '../components/stat/stat.recipe'slotRecipes: {// ...stat: statRecipe,},Then run
pnpm gen. This creates the typed functionstat()instyled-system/recipes. Becausepanda.config.tshasstaticCss: { recipes: '*' }, every variant of the recipe is written to the stylesheet whether or not anything uses it yet. -
Write the component.
src/components/stat/stat.tsx import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react'import { cx } from 'styled-system/css'import { stat } from 'styled-system/recipes'export interface StatProps extends ComponentPropsWithoutRef<'div'> {/** What the number is, e.g. "Bookings this week". */label: ReactNode/** The number, already formatted. */value: ReactNode/** sm inside a card, md on a dashboard. Default: md. */size?: 'sm' | 'md'}/** A label with a large value. */export const Stat = forwardRef<HTMLDivElement, StatProps>(function Stat({ label, value, size, className, ...rest },ref,) {const styles = stat({ size })return (<div ref={ref} className={cx(styles.root, className)} {...rest}><p className={styles.label}>{label}</p><p className={styles.value}>{value}</p></div>)})Rules for components:
- Write the props interface by hand, with a comment on every prop. Do not derive it from
Panda’s generated types: the published
.d.tsmust stand alone, and the comments become the props table on this site. Say the default in the comment (“Default: md.”). - Prop names and values match the Figma variant properties.
- Forward the ref, pass
classNamethroughcx(), and spread the rest onto the root. - For behaviour (popups, focus, keyboard), build on the matching Ark UI component, imported
by subpath:
import { Dialog } from '@ark-ui/react/dialog'.
- Write the props interface by hand, with a comment on every prop. Do not derive it from
Panda’s generated types: the published
-
Export it.
src/components/stat/index.ts export { Stat } from './stat'export type { StatProps } from './stat'src/index.ts export * from './components/stat' -
Write the stories.
The story title decides the group in Storybook and on this site: Layout, Forms, Data Display, Navigation, Overlay or Feedback.
src/components/stat/stat.stories.tsx import type { Meta, StoryObj } from '@storybook/react-vite'import { figma } from '../../stories/story-utils'import { Stat } from './stat'const meta = {title: 'Data Display/Stat',component: Stat,args: { label: 'Bookings this week', value: '128', size: 'md' },parameters: figma('12:345'), // node id of the component set in the Figma libraryargTypes: { size: { control: 'inline-radio', options: ['sm', 'md'] } },} satisfies Meta<typeof Stat>export default metatype Story = StoryObj<typeof meta>export const Playground: Story = {}Storybook runs the accessibility checks as errors (
a11y: { test: 'error' }), so a story that fails them fails the build. -
Write the tests.
Tests live in grouped files next to the component folders:
src/components/*.test.tsx(Vitest and Testing Library). Add adescribeblock to the file that fits, or a new file.describe('Stat', () => {it('shows the label and the value', () => {render(<Stat label="Bookings" value="128" size="sm" />)expect(screen.getByText('Bookings')).toBeInTheDocument()expect(screen.getByText('128').className).toContain('su-stat__value')})})Test what a user depends on: the accessible name, the keyboard behaviour, the callbacks.
-
Record the Figma counterpart.
Add the component to
figma/library.jsonundercomponents: its page id, the id of the component set, and its variant axes. The ids are read from Figma, never guessed. This file is the ledger that says which components exist in the library file and where. -
Add the page on this site.
- Put one file per example in
docs/src/examples/stat/. Each file default-exports a component and is shown as the code of the example. - Add
docs/src/content/docs/components/data-display/stat.mdx. Copy an existing page: import the examples, wrap each in<Example src="stat/basic">, and add<PropsTable of="Stat" />. - If the component passes through important Ark UI props (
open,value,onValueChange), list them underinheritedindocs/scripts/gen-props.mjs.
See Working on this site.
- Put one file per example in
-
Check, then add a changeset.
Terminal window pnpm typecheckTerminal window pnpm testTerminal window pnpm buildTerminal window pnpm changesetA new component is a
minorchange. See Releasing.