Skill

bon-ui

skills/bon-ui/SKILL.md

Minimal design system in this Next.js app. Not a published npm package. Humans start at the repo README; agents start here. Props: reference.md. Code wins if docs lag.

Package manager: pnpm. Dev: pnpm dev → http://localhost:1234.

If bon.config.ts version and this file’s metadata.version differ (or version is missing), stop and follow Version gate. Otherwise skip it.

Quick start

  1. Read this file, then reference.md for the component you need.
  2. Prefer existing layout + ui components over raw HTML + ad-hoc Tailwind.
  3. Merge classes with cn from @/lib/cn.
  4. Demo the pattern in playground first if unsure: /text /spacing /theme /grid /layout /components /table /form /chart /chat. Tabs that switch Panels: /layout. Live skill docs: /skill.

Hard rules

  • Use semantic tokens and layout components. Do not replace p-panel, gap-section-inner, text-body, text-muted-foreground with one-off p-4 / text-sm / hex colors.
  • Overlay and form controls: use @/components/ui/* compound APIs. Do not restyle @base-ui/react/* from scratch.
  • New controls go in components/ui/. New page composition (Panel / DataTable-like) goes in components/element/. New app chrome or docs infra goes in components/base/.
  • Button padding (h / px per size) stays component-local; it is not layout spacing.
  • Font weight (font-medium, font-semibold) stays on components; size/line-height come from semantic text-* utilities.
  • Keep ToastProvider, TooltipProvider, and ThemeTransitionProvider at the root (already in app/layout.tsx).

Design tokens

Three layers: primitive CSS vars in app/globals.css → semantic roles from bon.config.ts via lib/spacing.ts / lib/typography.ts / lib/palettes.ts → Tailwind @theme + @utility.

Config is injected in app/layout.tsx (getBonConfigCss(bonConfig) + html data-palette / data-button-radius / data-panel-radius). Theme apply is attrs + that CSS — not inline vars on html.

Color

Palettes: neutral | zinc | slate | rose | green (bon.config.ts → colors.palette). Switch in playground /theme.

Semantic utilities: bg-background, text-foreground, bg-muted, text-muted-foreground, border-border, bg-primary, text-primary-foreground, bg-gray, text-gray-foreground, outline-ring, bg-destructive / text-destructive, bg-info / text-info, bg-success / text-success, bg-warning / text-warning.

Mode: html.dark + data-palette. Toggle via ThemeToggle. Storage key bon-ui-theme (light | dark | system).

Spacing

Primitives: --bon-space-0 … --bon-space-16 (4px grid).

Config keyUtilityUse
pageXpx-page-xPage horizontal padding
pageY / pageYMobilepy-page-yPage vertical padding
sectionGapgap-section-gapBetween sections
sectionInnergap-section-innerStack inside a section/panel
panelPaddingp-panelCard inset
inlineGapgap-inlineButton groups, horizontal flex
stackTightgap-stack-tightTitle + subtitle

Text stacks (via TextStack gap): gap-text-stack-tight | gap-text-stack | gap-text-block | gap-text-section.

Typography

Sans: SF Pro via the system stack (--font-sans in app/globals.css). Apple OS uses SF Pro Text; other platforms fall back (Segoe UI, system-ui).

Primitives: xs | sm | base | lg | xl | 2xl | 3xl.

Config keyUtilityUse
pageTitletext-page-titlePage <h1>
sectionTitletext-section-titlePanel / section <h2>
bodytext-bodyParagraphs
captiontext-captionMetadata, labels
codetext-codeInline <code>
buttonSm / Md / Lgtext-button-*Button/input sizes (usually via components)

Typical page header:

<PageHead
  eyebrow="Eyebrow"
  title="Title"
  description="Lead"
>
  <Button variant="outline">Export</Button>
  <Button>New</Button>
</PageHead>

Shape

button.radius / button.borderWidth and panel.radius in config map to rounded-button, border-button, rounded-panel. Small boxes use rounded-control (min(button-radius, 6px) so a 16px checkbox never becomes a circle). Switch, Radio, Progress, and Slider use rounded-button.

Radius steps: none | sm | md | lg | xl | 2xl. Border width: none | sm | md | lg.

Layout rules

NeedComponentImport
App chrome (header + sidebar + main)AppLayout@/components/base/app-layout
Centered content page (no chrome)Page@/components/element/page
Page title rowPageHead@/components/element/page
Unbordered content blockSection@/components/ui/section
Bordered cardPanel@/components/element/panel
12-col gridGrid + GridCol@/components/element/grid
Vertical text rhythmTextStack@/components/element/text-stack
  • Playground/docs/templates pages use AppLayout then PageHead + Panels inside MainContainer (do not wrap those in Page).
  • Page chrome: PageHead (eyebrow / title / description left, action buttons as children).
  • Panel chrome: PanelHead (title / description left, control buttons as children) and PanelFoot (description left, right-aligned actions as children).
  • Overlay chrome: DialogHead / DialogFoot outside DialogContent (maxHeight, default 60vh) so the body can scroll. DrawerHead / DrawerFoot outside DrawerContent. Do not also pass showClose on the popup.
  • Page is for standalone content: px-page-x, py-page-y, gap-section-gap, maxWidth default "2xl".
  • Grid is always 12 columns. gap: section (default) | inline | none. GridCol span is 1–12 or { default, sm, md, lg, xl, 2xl }.
  • Compose chrome from primitives only when AppLayout is too opinionated. Those live in components/base/: SidebarLayout, StickyHeader, Sidebar, MainContainer, HeaderNav, SidebarNav / SidebarNavGroup, Logo.

Where to put a file

FolderPut hereExamples
components/base/App chrome and playground/docs infraAppLayout, sidebar/header primitives, Markdown
components/element/Page regions and composed featuresPage, Panel, Grid, TextStack, DataTable
components/ui/Controls, overlays, status (usually Base UI)Button, Dialog, Notice, Loading, Progress*, Table*
components/ rootCross-cuttingicons, theme-toggle, theme-transition

Section is an unbordered layout region; it stays in ui. Markdown is the skill-docs renderer; it stays in base.

Component map

AreaComponents
Layout / chromeAppLayout (base), Page / Panel / Grid / TextStack / DataTable (element), Section (ui)
ActionsButton, ButtonGroup, OverflowList, Toggle
StatusBadge, Avatar / AvatarGroup, Notice, Loading, Progress*
KeysKbd, KbdGroup
FormFormRoot, Input, Textarea, Field*, FieldGroup*, Detail*, DetailGroup*, Stat*, StatGroup*, FilterField, Switch, Checkbox*, Radio*, Select*, Slider*
NavigationTabs*, Step*, CheckList*
OverlayMenu*, Popover*, Tooltip*, Dialog*, AlertDialog*, Drawer*, Toast* + useToastManager
TableTable* primitives, DataTable, DataTableToolbar, StatusFilter, NumberValue, EmailValue
ChartChartRoot (type bar/line/combo/grid; multi-series via series; bar stacking; combo dual Y-axes; grid square area projection)
ChatMessage*, Composer*
ThemeThemeToggle, useAnimatedTheme, useThemeTransition
Icons@/components/icons

Triggers that open overlays use Base UI render={<Button … />} (do not nest <button>).

Fused action bar (ButtonGroup) vs separate buttons (gap-inline):

<ButtonGroup aria-label="Text alignment">
  <Button variant="outline">Left</Button>
  <Button variant="outline">Center</Button>
  <Button variant="outline">Right</Button>
</ButtonGroup>

<div className="flex flex-wrap items-center gap-inline">
  <Button variant="outline">Cancel</Button>
  <Button>Save</Button>
</div>

ButtonGroup orientation: horizontal (default) | vertical. Non-first primary / gray children get an ::after hairline on the joining edge. Outline uses the collapsed shared border only (no extra line). Pass aria-label when the group has no visible name. Not a toggle/segmented control.

A row that collapses into a more menu uses OverflowList (demo: /components). Separate buttons take className="gap-inline". A fused outline row is not a ButtonGroup: each item is wrapped, so overlap non-first wrappers with -ml-px and round the first and last button. A negative gap does not collapse that shared border.

Toggle

Icon-button style two-state control (pressed / defaultPressed / onPressedChange). Not Switch (form track) and not ButtonGroup (no selection). Demo: /components.

<Toggle icon={<SettingsIcon />} aria-label="Settings" />
<Toggle defaultPressed icon={<SettingsIcon />} aria-label="Pinned" />
<Toggle pressed={on} onPressedChange={setOn} icon={<SettingsIcon />}>
  Pin
</Toggle>

variant (default outline): outline | ghost | gray | primary. size: sm | md | lg. Icon-only: icon with no children — pass aria-label. Pressed fills primary; primary variant inverts to muted when pressed.

Composition recipes

Field + Input

<FieldRoot name="email">
  <FieldLabel>Email</FieldLabel>
  <Input type="email" placeholder="you@example.com" />
  <FieldDescription>We never share your email.</FieldDescription>
</FieldRoot>

<FieldRoot name="email">
  <FieldLabel required>Email</FieldLabel>
  <Input type="email" placeholder="you@example.com" />
</FieldRoot>

<FieldRoot name="nickname">
  <FieldLabel optional>Nickname</FieldLabel>
  <Input placeholder="Optional display name" />
</FieldRoot>

<FieldRoot name="email" invalid>
  <FieldLabel>Email</FieldLabel>
  <Input type="email" />
  <FieldError match>Enter a valid email address.</FieldError>
</FieldRoot>

FieldLabel required shows a destructive asterisk icon; optional shows muted (optional). Visual only — set HTML required on the control when validation needs it. When both are set, required wins.

FieldGroup

Groups related fields in an accessible fieldset. columns is equal tracks (1–4); stacks to 1 col below md. Demo: /form.

<FieldGroupRoot variant="gray">
  <FieldGroupSection title="Profile" description="Public identity." />
  <FieldGroup columns={2}>
    <FieldRoot name="firstName">
      <FieldLabel>First name</FieldLabel>
      <Input />
    </FieldRoot>
    <FieldRoot name="lastName">
      <FieldLabel>Last name</FieldLabel>
      <Input />
    </FieldRoot>
  </FieldGroup>
  <FieldGroupDivider />
  <FieldGroupSection title="Contact" />
  <FieldGroup>
    <FieldRoot name="email">
      <FieldLabel>Email</FieldLabel>
      <Input type="email" />
    </FieldRoot>
  </FieldGroup>
</FieldGroupRoot>

FieldGroupRoot variant: default | gray (muted padded surface). FieldGroup gap: section (default) | inline. Prefer one FieldGroupSection legend per root when possible.

FormRoot

Wraps FieldGroup (or mixed Detail + Field) and collects named FieldRoot values. Demo: /form → Form tab.

<FormRoot
  className="max-w-2xl"
  onFormSubmit={(values) => {
    // { firstName, lastName, email }
  }}
>
  <FieldGroupRoot variant="gray">
    <FieldGroupSection title="Profile" />
    <FieldGroup columns={2}>
      <FieldRoot name="firstName">
        <FieldLabel>First name</FieldLabel>
        <Input />
      </FieldRoot>
      <FieldRoot name="lastName">
        <FieldLabel>Last name</FieldLabel>
        <Input />
      </FieldRoot>
    </FieldGroup>
  </FieldGroupRoot>
  <div className="flex justify-end gap-inline">
    <Button type="submit">Save</Button>
  </div>
</FormRoot>

Only FieldRoot with name contribute values; Detail rows stay display-only. Pass errors={{ email: "…" }} for server/action field errors. Optional validationMode (onSubmit default | onBlur | onChange) and actionsRef (validate(fieldName?)).

Detail + DetailGroup

Static label/value rows for profile-style display. Not editable fields. Demo: /form.

<DetailRoot>
  <DetailLabel>Email</DetailLabel>
  <DetailValue>you@example.com</DetailValue>
</DetailRoot>

<DetailRoot orientation="horizontal">
  <DetailLabel>Timezone</DetailLabel>
  <DetailValue>Asia/Shanghai (UTC+8)</DetailValue>
</DetailRoot>

<DetailGroupRoot variant="gray">
  <DetailGroupSection title="Profile" description="Public identity." />
  <DetailGroup columns={2}>
    <DetailRoot>
      <DetailLabel>First name</DetailLabel>
      <DetailValue>Ava</DetailValue>
    </DetailRoot>
    <DetailRoot>
      <DetailLabel>Last name</DetailLabel>
      <DetailValue>Chen</DetailValue>
    </DetailRoot>
  </DetailGroup>
  <DetailGroupDivider />
  <DetailGroupSection title="Contact" />
  <DetailGroup>
    <DetailRoot orientation="horizontal">
      <DetailLabel>Email</DetailLabel>
      <DetailValue>you@example.com</DetailValue>
    </DetailRoot>
  </DetailGroup>
</DetailGroupRoot>

DetailRoot orientation: vertical (default, label above value) | horizontal (label left, fixed w-32). Empty DetailValue children render an em dash. DetailGroupRoot / DetailGroup mirror FieldGroup (variant, columns 1–4) but use a section + h3 (not a fieldset).

Stat + StatGroup

Dashboard KPI cards: bordered item with hint (optional top-right action), large value + label, optional change footer. Grid via StatGroup. Demo: /layout.

<StatGroup columns={3}>
  <StatRoot>
    <StatHead>
      <StatHint>Post views improve visibility in feeds</StatHint>
      <StatAction>
        <Button size="sm" variant="ghost" icon={<SettingsIcon />} aria-label="More" />
      </StatAction>
    </StatHead>
    <StatMetric>
      <StatValue>439</StatValue>
      <StatLabel>Post views</StatLabel>
    </StatMetric>
    <StatChange direction="down" value="2.8K">
      from previous month
    </StatChange>
  </StatRoot>
  <StatRoot>
    <StatHint>Followers grow visibility and engagement</StatHint>
    <StatMetric>
      <StatValue>0</StatValue>
      <StatLabel>Total followers</StatLabel>
    </StatMetric>
    <StatChange>No change from previous month</StatChange>
  </StatRoot>
</StatGroup>

StatHead is hint left + StatAction right (button / menu / badge). Bare StatHint without StatHead still works. StatChange with value shows a Badge (badgeVariant default warning) + optional direction arrow (up | down | none); omit value for muted text only. Compose NumberValue inside StatValue when units/formatting are needed. StatGroupRoot / StatGroup mirror DetailGroup (variant, columns 1–4).

Field + Textarea

<FieldRoot name="bio">
  <FieldLabel>Bio</FieldLabel>
  <Textarea placeholder="A short introduction." />
  <FieldDescription>Shown on your public profile.</FieldDescription>
</FieldRoot>

<FieldRoot name="feedback" invalid>
  <FieldLabel>Feedback</FieldLabel>
  <Textarea />
  <FieldError match>Write at least 20 characters.</FieldError>
</FieldRoot>

Labeled controls: wrap Checkbox / Switch / Radio in CheckboxItem / SwitchItem / RadioItem.

Dialog

<DialogRoot>
  <DialogTrigger render={<Button variant="outline" />}>Open</DialogTrigger>
  <DialogPortal>
    <DialogBackdrop />
    <DialogViewport>
      <DialogPopup>
        <DialogHead title="Edit profile" description="…" showClose />
        <DialogContent maxHeight="16rem">…</DialogContent>
        <DialogFoot>
          <DialogClose render={<Button />}>OK</DialogClose>
        </DialogFoot>
      </DialogPopup>
    </DialogViewport>
  </DialogPortal>
</DialogRoot>

Alert (must click an action): same stack with AlertDialog*. Destructive confirm: variant="destructive" / outline-destructive.

Menu (dropdown actions): MenuRoot → Trigger → Portal → Positioner → MenuPopup → MenuItem / MenuSeparator / MenuCheckboxItem / MenuRadioGroup+MenuRadioItem / MenuSubmenuRoot.

<MenuRoot>
  <MenuTrigger render={<Button variant="outline" />}>Open</MenuTrigger>
  <MenuPortal>
    <MenuPositioner>
      <MenuPopup>
        <MenuItem>New file<MenuShortcut>⌘N</MenuShortcut></MenuItem>
        <MenuSeparator />
        <MenuItem variant="destructive">Delete</MenuItem>
      </MenuPopup>
    </MenuPositioner>
  </MenuPortal>
</MenuRoot>

Submenu: MenuSubmenuRoot → MenuSubmenuTrigger → Portal → MenuPositioner side="right" → Popup.

Popover (anchored, not modal): PopoverRoot → Trigger → Portal → Positioner → PopoverPopup showClose? → optional PopoverArrow / Title / Description / Close.

<PopoverRoot>
  <PopoverTrigger render={<Button variant="outline" />}>Open</PopoverTrigger>
  <PopoverPortal>
    <PopoverPositioner>
      <PopoverPopup className="flex w-72 flex-col gap-section-inner">
        <PopoverTitle>Title</PopoverTitle>
        <PopoverDescription>…</PopoverDescription>
        <div className="flex justify-end">
          <PopoverClose render={<Button size="sm" />}>Done</PopoverClose>
        </div>
      </PopoverPopup>
    </PopoverPositioner>
  </PopoverPortal>
</PopoverRoot>

Tooltip (hover/focus hint): TooltipRoot → Trigger → Portal → Positioner → TooltipPopup → optional TooltipArrow. Root layout already wraps TooltipProvider.

<TooltipRoot>
  <TooltipTrigger render={<Button variant="outline" icon={<PlusIcon />} />} />
  <TooltipPortal>
    <TooltipPositioner>
      <TooltipPopup>Add item</TooltipPopup>
    </TooltipPositioner>
  </TooltipPortal>
</TooltipRoot>

Disabled trigger: wrap a span around the disabled control so hover still works.

Tabs (switch Panels; demo /layout):

<TabsRoot defaultValue="overview">
  <TabsList>
    <TabsTab value="overview" icon={<LayoutIcon />}>Overview</TabsTab>
    <TabsTab value="details">Details</TabsTab>
    <TabsIndicator />
  </TabsList>
  <TabsPanel value="overview">
    <Panel>…</Panel>
  </TabsPanel>
  <TabsPanel value="details">
    <Panel>…</Panel>
  </TabsPanel>
</TabsRoot>

<TabsRoot type="segmented" size="sm" defaultValue="account">
  <TabsList>
    <TabsTab value="account" icon={<SettingsIcon />}>Account</TabsTab>
    <TabsTab value="password" icon={<FormIcon />}>Password</TabsTab>
    <TabsIndicator />
  </TabsList>
  <TabsPanel value="account">…</TabsPanel>
  <TabsPanel value="password">…</TabsPanel>
</TabsRoot>

type (default line): line | segmented. Segmented uses a muted pill track; TabsIndicator slides as the solid primary background behind the active tab. size (default md): sm | md.

Step (wizard progress; demo /components):

<StepRoot value={step} onValueChange={setStep} aria-label="Onboarding">
  <StepList>
    <StepItem value="account">
      <StepIndicator />
      <StepTitle>Account</StepTitle>
      <StepDescription>Profile basics</StepDescription>
      <StepSeparator />
    </StepItem>
    <StepItem value="details">
      <StepIndicator />
      <StepTitle>Details</StepTitle>
      <StepDescription>Workspace info</StepDescription>
      <StepSeparator />
    </StepItem>
    <StepItem value="confirm">
      <StepIndicator />
      <StepTitle>Confirm</StepTitle>
      <StepDescription>Review</StepDescription>
    </StepItem>
  </StepList>
</StepRoot>

orientation: horizontal (default) | vertical. linear (default true) blocks skipping ahead. value={undefined} or value="" leaves every step upcoming (not started; prefer "" when clearing a controlled value); value={null} marks every step completed. Parent owns step body content.

CheckList (independent onboarding tasks; demo /components):

<CheckListRoot value={done} onValueChange={setDone} aria-label="Setup">
  <CheckListList>
    <CheckListItem value="asset">
      <CheckListIndicator />
      <CheckListTitle>Add your first asset</CheckListTitle>
      <CheckListDescription>Upload a host or cloud account.</CheckListDescription>
    </CheckListItem>
    <CheckListItem value="credentials">
      <CheckListIndicator />
      <CheckListTitle>Set up credentials</CheckListTitle>
    </CheckListItem>
  </CheckListList>
</CheckListRoot>

value is completed item ids (any order). size: sm | md (default). Click indicator or title to toggle (interactive={false} for display-only); description is not clickable. Completed uses a solid green filled check + strikethrough title; error / progress use outline indicators (XIcon / CircleHalfIcon) with semantic title colors (priority: error > progress > completed > pending). Optional CheckListDescription under the title (completed: strikethrough + half opacity). Vertical only in v1. Not a form Checkbox.

Drawer: DrawerRoot → Portal → Backdrop → DrawerViewport side="right"|"bottom" → DrawerPopup side={…} → DrawerHead → DrawerContent → DrawerFoot. Head and foot stay outside DrawerContent so long bodies scroll in the middle. Do not also pass showClose on DrawerPopup when the head already shows close.

<DrawerPopup side="right">
  <DrawerHead title="Settings" description="…" showClose>
    <Button size="sm">Save</Button>
  </DrawerHead>
  <DrawerContent>…</DrawerContent>
  <DrawerFoot description="Unsaved changes">
    <DrawerClose render={<Button variant="outline" />}>Cancel</DrawerClose>
    <DrawerClose render={<Button />}>Save</DrawerClose>
  </DrawerFoot>
</DrawerPopup>

Toast

Root layout already wraps ToastProvider. Call useToastManager() in a client component:

toast.add({ title: "Saved" });
toast.add({ title: "Failed", description: "Try again.", type: "error" });
toast.add({ title: "Saved", type: "success", actionProps: { children: "Undo" } });
toast.add({ title: "Low balance", type: "warning" });
toast.add({ title: "Saved", size: "lg" });

Layout matches Notice: status icon | title + description | action | dismiss. Solid bg-background (not tinted). size (default md): md | lg — same scale as Notice. Types: success | error | warning | loading | default (info). Accents/icons/dismiss shared with Notice via status-surface. When two or more toasts are open, Clear all appears at the top-right of the stack only while it is expanded (hover or focus) and dismisses every toast.

Notice

Inline page banner. Not a toast. Parent owns dismiss visibility.

<Notice
  variant="info"
  title="Workspace is in read-only mode"
  description="Billing is paused. Restore a plan to edit."
  onDismiss={() => setOpen(false)}
>
  <Button variant="outline" size="sm">Restore</Button>
</Notice>

variant: info (default) | success | warning | error. Surfaces and accents use info / success / warning / destructive. error uses role="alert"; others use role="status".

size (default md): md | lg. md is tighter padding with body/caption type; lg uses p-panel, section title, and body copy. Icon box/stroke scale with size (size-4 / stroke-[1.75] vs size-5.5 / stroke-[1.5]). Built-in status glyphs live in @/components/ui/notice-icons (NoticeInfoIcon / NoticeSuccessIcon / NoticeWarningIcon / NoticeErrorIcon) — separate from Badge Circle* icons.

Loading

Centered body placeholder. Not a button spinner and not a full-page splash. Icon, then LoaderIcon, then copy. Parent keeps chrome (PanelHead / DialogHead).

<Panel className="min-h-64">
  <PanelHead title="Members" />
  <Loading icon={<SettingsIcon />}>Loading members…</Loading>
</Panel>

<DialogPopup>
  <DialogHead title="Export" showClose />
  <DialogContent>
    <Loading icon={<TableIcon />}>Preparing export…</Loading>
  </DialogContent>
</DialogPopup>

icon is optional. Omit it for spinner + copy only. Button loading stays Button + LoaderIcon.

Progress

Read-only completion bar (not a form control). Compound: ProgressRoot + ProgressLabel / ProgressValue / optional ProgressHint + ProgressTrack (ProgressIndicator for bar). value={null} is indeterminate. Track size: xs | sm | md; variant: bar | segments. Demo: /components; table cell: /table.

Chart

Charts via ChartRoot. type: "bar" | "line" | "combo" | "grid". Single-series { label, value } or multi-series with series (grid is single-series only). Bar stacking: "grouped" (default) | "stacked". Combo overlays bars + lines with shared category labels and optional dual Y-axes. Grid projects the area under the series line onto a field of equal square cells. Demo: /chart.

<ChartRoot
  type="bar"
  data={[
    { label: "Mon", value: 42 },
    { label: "Tue", value: 28 },
  ]}
  aria-label="Weekly visits"
  className="h-64"
  barSize={24}
  barGap={16}
  showValue
/>

<ChartRoot
  type="grid"
  data={[
    { label: "Mon", value: 42 },
    { label: "Tue", value: 28 },
  ]}
  gridRows={16}
  gridCols={40}
  gridSize={10}
  gridGap={2}
  showYAxis
  aria-label="Weekly visits grid"
/>

<ChartRoot
  type="line"
  data={[
    { label: "Mon", value: 42 },
    { label: "Tue", value: 28 },
  ]}
  showValue
  aria-label="Weekly visits line"
/>

<ChartRoot
  type="line"
  data={[
    { label: "Fri", value: 61 },
    { label: "Sat", value: 19, dashed: true },
    { label: "Sun", value: 24, dashed: true },
  ]}
  aria-label="Weekly visits with a dashed forecast"
/>

<ChartRoot
  type="line"
  curve
  fill
  lineSize={3}
  dotSize={0}
  data={[
    { label: "Mon", value: 42 },
    { label: "Tue", value: 28 },
  ]}
  aria-label="Weekly visits curved"
/>

<ChartRoot
  type="bar"
  stacking="stacked"
  data={[
    { label: "Mon", desktop: 40, mobile: 24 },
    { label: "Tue", desktop: 28, mobile: 32 },
  ]}
  series={[
    { key: "desktop", label: "Desktop" },
    { key: "mobile", label: "Mobile", color: "muted" },
  ]}
  barSize={24}
  showValue
  aria-label="Traffic by channel"
/>

<ChartRoot
  type="combo"
  data={[
    { label: "Mon", visits: 420, rate: 0.12 },
    { label: "Fri", visits: 590, rate: 0.19, colors: { visits: "success" } },
  ]}
  series={[
    { key: "visits", label: "Visits", type: "bar" },
    { key: "rate", label: "Rate", type: "line", color: "info", yAxis: "right" },
  ]}
  showYAxis
  aria-label="Visits and conversion rate"
/>

barSize / barGap / seriesGap / stacking apply to bars (and combo bar series). curve smooths line / combo lines (default straight). fill paints a vertical gradient under the line (series color 50% → 0). lineSize (px, default 2) sets stroke width. dotSize (px, default 8) sets marker diameter; 0 hides markers. pointAlign "center" (default, matches labels) | "edge" (first/last on plot edges with even spacing; category labels use the same x positions). Combo: each series needs type: "bar" | "line"; yAxis "left" | "right" (default bar→left, line→right). type="grid": single-series square cell field; samples the area under the line (same pointAlign / optional curve) onto gridRows×gridCols (defaults 20×48); cell edge gridSize (default 8) and gridGap (default 2) set plot size (rows|cols * size + (n-1) * gap); Y-axis height matches the plot so ticks align; empty cells bg-muted, covered cells use series/color/nearest-datum color; Y-axis default on. showYAxis (true | false | { left?, right? }) toggles numeric tick labels (combo/grid default on; bar/line default off). emptyVariant "panel" (default muted placeholder) | "plot" (grid + centered “No Data”; keeps legend / Y-axis / category-label slots to avoid layout jump). showValue labels each bar (stacked: category sum) or each line point. showLabel (default true) toggles category labels under the plot. labelInterval (default 1) shows every Nth category label when dense (first and last always kept). color (default "primary") sets the single-series token when series is omitted; with series, use series[].color. Per-datum color overrides the series token for that category on single-series bars, line markers, and nearest grid columns. Per-datum colors: { [seriesKey]: token } overrides one series at a category on multi-series / combo (other series untouched; line stroke/fill stay series-colored). Per-datum dashed on a single-series line dashes the segment that arrives at that point; the previous point stays the join so the solid and dashed strokes stay connected. Multi-series and combo lines use dashes: { [seriesKey]: true } the same way colors overrides one series. Consecutive dashed points are one dashed run. Area fill stays solid. Series color: primary | muted | gray | info | success | warning (cycles when omitted). Legend shows when series.length > 1 (not for grid). Empty data uses emptyVariant. Pass aria-label (required).

DataTable

Use DataTable for sort / filter / pagination / pinned columns. Use Table* only for static markup. variant: "default" | "gray" (muted background, rounded-button) | "bordered" (cell grid inside a rounded-button frame, for later item edit mode). Demo: /table.

<DataTable
  columns={columns}
  data={rows}
  filterPlaceholder="Filter…"
  initialPageSize={10}
  selectable
  columnPinning={{ left: ["name"], right: ["actions"] }}
  getRowId={(row) => row.id}
/>

Chip filters live outside DataTable. Hide the built-in search with showFilter={false}. Pass required on a FilterField to keep it applied (no ×); skip it in onClear.

<DataTableToolbar
  showClear={hasFilters}
  onClear={() => setFilters({})}
  actions={<Button variant="outline" size="sm">Export</Button>}
>
  <FilterField label="Status" options={["active", "invited"]} value={status} onValueChange={setStatus} required />
</DataTableToolbar>
<DataTable columns={columns} data={filteredRows} showFilter={false} />

A status strip with counts sits above the table. variant is line (default underline), button (outline row), or card (title, optional description, and count). The parent owns the selected value and passes count from the list before the status slice, so the numbers stay stable. Include an All option when every row should be reachable. Clicking the active item does not clear it.

<StatusFilter
  aria-label="Order status"
  variant="card"
  value={status}
  onValueChange={setStatus}
  options={[
    { value: "all", label: "All", count: rows.length, description: "Every order" },
    { value: "paid", label: "Paid", count: paidCount, description: "Payment received" },
  ]}
/>
<DataTable columns={columns} data={filteredRows} showFilter={false} />

Sort is per ColumnDef. Turn it off on action columns:

{ id: "actions", header: "Actions", enableSorting: false, cell: () => <Button>Edit</Button> }

size is a fixed width. Omit it so the column fills leftover space. Flexible columns (no size) honor minSize as a shrink floor (default 96); when the wrap is narrower than the sum of floors, the table scrolls horizontally. Use truncate in the cell for long text within that floor.

{ accessorKey: "name", header: "Name", size: 160 }
{ accessorKey: "email", header: "Email", minSize: 160 }

Number cells: compose NumberValue inside cell (unit before for $ / ¥, after for %):

{
  accessorKey: "price",
  header: "Price",
  size: 100,
  cell: ({ row }) => (
    <NumberValue value={row.original.price} unit="$" unitPosition="before" />
  ),
}
{
  accessorKey: "rate",
  header: "Rate",
  size: 80,
  cell: ({ row }) => <NumberValue value={row.original.rate} unit="%" color="auto" />,
}

Email cells: compose EmailValue (local-part font-medium, @domain muted):

{
  accessorKey: "email",
  header: "Email",
  cell: ({ row }) => <EmailValue email={row.original.email} />,
}

Chat

Thread of messages plus the composer that posts the next one. Demo: /chat.

<MessageList aria-label="Design thread">
  <MessageRoot side="incoming">
    <MessageAvatar>
      <AvatarRoot size="sm">
        <AvatarFallback>AC</AvatarFallback>
      </AvatarRoot>
    </MessageAvatar>
    <MessageContent>
      <MessageMeta>
        <MessageAuthor>Ava Chen</MessageAuthor>
        <MessageTime>2:14 PM</MessageTime>
      </MessageMeta>
      <MessageBody>The calendar ships Friday.</MessageBody>
    </MessageContent>
  </MessageRoot>
  <MessageRoot side="outgoing">
    <MessageContent>
      <MessageBody>I’ll review the notes tonight.</MessageBody>
    </MessageContent>
  </MessageRoot>
  <MessageRoot side="agent">
    <MessageContent>
      <MessageMeta>
        <MessageAuthor>Agent</MessageAuthor>
        <MessageTime>2:26 PM</MessageTime>
      </MessageMeta>
      <MessageBody>I’ll keep the notes in view.</MessageBody>
    </MessageContent>
  </MessageRoot>
</MessageList>

<ComposerRoot onSubmit={(text) => append(text)}>
  <ComposerInput placeholder="Tell AI what changes to make — @ to reference media" />
  <ComposerFoot>
    <ComposerMode />
    <ComposerActions>
      <ComposerAttach />
      <ComposerSubmit />
    </ComposerActions>
  </ComposerFoot>
</ComposerRoot>

MessageList maxHeight (default "24rem") turns on overflow-y-auto. Pass "none" to grow with content. A new message smooth-scrolls into view while the reader is already at the end. MessageScroll to="end" jumps back to the newest message; to as a MessageRoot id jumps to that message. The button hides while the target is already in view. MessageRoot side (default incoming): incoming | outgoing | agent. Incoming uses a muted bubble; outgoing uses a primary bubble and the row aligns to the end. agent is left-aligned plain text at full width: no bubble background, radius, or padding. Avatar is optional. MessageStatus replaces MessageBody while a reply is loading. Incoming and outgoing keep the bubble around a pulsing placeholder bar; agent shows the bar alone. Children are the accessible label (default Loading); the node is role="status" with aria-busy and aria-live="polite". Enter submits; Shift+Enter inserts a newline. The field grows with each new line up to maxRows (default 6), then scrolls. Submit stays disabled while the trimmed value is empty. onSubmit receives the trimmed string. An uncontrolled field clears after a successful submit. Pass value / onValueChange to control it.

Theme transition

const { startTransition } = useThemeTransition();
startTransition(event, { onUpdate: () => { /* toggle theme */ } });

Or ThemeToggle / useAnimatedTheme from @/components/theme-toggle.

Do / Don’t

Do

  • Match playground copy: PageHead for the page title row, Panel + text-section-title for blocks.
  • Separate buttons: flex flex-wrap items-center gap-inline. Fused toolbar: ButtonGroup.
  • Extend tokens by adding primitive + config key + CSS fallback + @theme / @utility (see below).

Don’t

  • Import primitives from @base-ui/react in app pages.
  • Invent new Button/Badge variants without adding them to the component file.
  • Put layout spacing on buttons, or typography size on bon.config via ad-hoc text-2xl for page titles (exception: rare display headings already in playground).
  • Add a second toast viewport; ToastProvider already mounts the list.
  • Remount TooltipProvider on a page; it is already at the root.

Extending tokens

New spacing primitive: --bon-space-N in globals.css + BonSpaceStep / bonSpaceValues in lib/spacing.ts.

New spacing role: field on BonSpacingConfig + default + spacingCssVarMap + :root fallback + @theme + @utility + document the class.

New type role: same flow in lib/typography.ts / globals.css.

New palette: extend bonPalettes in lib/palettes.ts, then set colors.palette.

BonSpacingDensity / BonTypographyDensity are reserved; do not implement presets unless asked.

Version gate

Only when versions differ, the user asked to upgrade bon-ui, or bonConfig.version is missing. Do not re-check on every UI edit.

  1. Read this file’s frontmatter metadata.version → skillVersion.
  2. Read the project’s bon.config.ts → bonConfig.version (sourceVersion). Use the field, not a comment.
  3. Compare as semver:
    • Equal → continue.
    • skill > source → source is behind. Stop. Do not invent APIs from newer docs.
    • skill < source → this skill is stale. Run npx skills add github/qilei0529/bon-ui, then re-read this file.
    • No version on bonConfig → unstamped copy. Ask whether the source already matches this skill (add the field only) or needs a full refresh. Do not assume it is current.

Before any upgrade, list the change points the user must confirm:

  1. Diff sourceVersion → skillVersion (or the version they asked to upgrade to).
  2. Pull items from kit CHANGELOG.md (repo root; if only the skill is installed, fetch changelog only for this upgrade).
  3. Show a short checklist: version range, breaking vs added vs fix, and which consumer files would be overwritten or merged (components/, lib/, token CSS, bon.config.ts keys).
  4. Wait until the user explicitly confirms those items. A vague “upgrade” is not enough. If they reject some points, do not apply those files.

Do not hit GitHub on every task. Fetch the upstream kit only when the user asks to upgrade bon-ui.

Never overwrite consumer files silently (bon.config.ts and edited components especially). After the listed change points are confirmed:

  1. Refresh the skill: npx skills add github/qilei0529/bon-ui.
  2. Copy kit source over components/, lib/, and token CSS in app/globals.css (see the repo README).
  3. Merge bon.config.ts: bump version to match the skill; keep the project’s palette / radius / spacing / typography values; add only new keys.
  4. Keep getBonConfigCss(bonConfig) + html theme attrs (data-palette, data-button-radius, data-panel-radius) and ToastProvider, TooltipProvider, ThemeTransitionProvider on the root layout.
  5. Re-run this gate. Write product UI only when the two versions match.