Skill
bon-ui
skills/bon-ui/SKILL.mdMinimal 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
- Read this file, then reference.md for the component you need.
- Prefer existing layout + ui components over raw HTML + ad-hoc Tailwind.
- Merge classes with
cnfrom@/lib/cn. - Demo the pattern in playground first if unsure:
/text/spacing/theme/grid/layout/components/table/form/chart/chat. Tabs that switchPanels:/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-foregroundwith one-offp-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 incomponents/element/. New app chrome or docs infra goes incomponents/base/. - Button padding (
h/pxper size) stays component-local; it is not layout spacing. - Font weight (
font-medium,font-semibold) stays on components; size/line-height come from semantictext-*utilities. - Keep
ToastProvider,TooltipProvider, andThemeTransitionProviderat the root (already inapp/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 key | Utility | Use |
|---|---|---|
pageX | px-page-x | Page horizontal padding |
pageY / pageYMobile | py-page-y | Page vertical padding |
sectionGap | gap-section-gap | Between sections |
sectionInner | gap-section-inner | Stack inside a section/panel |
panelPadding | p-panel | Card inset |
inlineGap | gap-inline | Button groups, horizontal flex |
stackTight | gap-stack-tight | Title + 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 key | Utility | Use |
|---|---|---|
pageTitle | text-page-title | Page <h1> |
sectionTitle | text-section-title | Panel / section <h2> |
body | text-body | Paragraphs |
caption | text-caption | Metadata, labels |
code | text-code | Inline <code> |
buttonSm / Md / Lg | text-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
| Need | Component | Import |
|---|---|---|
| App chrome (header + sidebar + main) | AppLayout | @/components/base/app-layout |
| Centered content page (no chrome) | Page | @/components/element/page |
| Page title row | PageHead | @/components/element/page |
| Unbordered content block | Section | @/components/ui/section |
| Bordered card | Panel | @/components/element/panel |
| 12-col grid | Grid + GridCol | @/components/element/grid |
| Vertical text rhythm | TextStack | @/components/element/text-stack |
- Playground/docs/templates pages use
AppLayoutthenPageHead+Panels insideMainContainer(do not wrap those inPage). - Page chrome:
PageHead(eyebrow/title/descriptionleft, action buttons as children). - Panel chrome:
PanelHead(title/descriptionleft, control buttons as children) andPanelFoot(descriptionleft, right-aligned actions as children). - Overlay chrome:
DialogHead/DialogFootoutsideDialogContent(maxHeight, default60vh) so the body can scroll.DrawerHead/DrawerFootoutsideDrawerContent. Do not also passshowCloseon the popup. Pageis for standalone content:px-page-x,py-page-y,gap-section-gap,maxWidthdefault"2xl".Gridis always 12 columns.gap:section(default) |inline|none.GridColspanis1–12or{ default, sm, md, lg, xl, 2xl }.- Compose chrome from primitives only when
AppLayoutis too opinionated. Those live incomponents/base/:SidebarLayout,StickyHeader,Sidebar,MainContainer,HeaderNav,SidebarNav/SidebarNavGroup,Logo.
Where to put a file
| Folder | Put here | Examples |
|---|---|---|
components/base/ | App chrome and playground/docs infra | AppLayout, sidebar/header primitives, Markdown |
components/element/ | Page regions and composed features | Page, Panel, Grid, TextStack, DataTable |
components/ui/ | Controls, overlays, status (usually Base UI) | Button, Dialog, Notice, Loading, Progress*, Table* |
components/ root | Cross-cutting | icons, 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
| Area | Components |
|---|---|
| Layout / chrome | AppLayout (base), Page / Panel / Grid / TextStack / DataTable (element), Section (ui) |
| Actions | Button, ButtonGroup, OverflowList, Toggle |
| Status | Badge, Avatar / AvatarGroup, Notice, Loading, Progress* |
| Keys | Kbd, KbdGroup |
| Form | FormRoot, Input, Textarea, Field*, FieldGroup*, Detail*, DetailGroup*, Stat*, StatGroup*, FilterField, Switch, Checkbox*, Radio*, Select*, Slider* |
| Navigation | Tabs*, Step*, CheckList* |
| Overlay | Menu*, Popover*, Tooltip*, Dialog*, AlertDialog*, Drawer*, Toast* + useToastManager |
| Table | Table* primitives, DataTable, DataTableToolbar, StatusFilter, NumberValue, EmailValue |
| Chart | ChartRoot (type bar/line/combo/grid; multi-series via series; bar stacking; combo dual Y-axes; grid square area projection) |
| Chat | Message*, Composer* |
| Theme | ThemeToggle, 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:
PageHeadfor the page title row,Panel+text-section-titlefor 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/reactin app pages. - Invent new Button/Badge variants without adding them to the component file.
- Put layout spacing on buttons, or typography size on
bon.configvia ad-hoctext-2xlfor page titles (exception: rare display headings already in playground). - Add a second toast viewport;
ToastProvideralready mounts the list. - Remount
TooltipProvideron 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.
- Read this file’s frontmatter
metadata.version→skillVersion. - Read the project’s
bon.config.ts→bonConfig.version(sourceVersion). Use the field, not a comment. - 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
versiononbonConfig→ 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:
- Diff
sourceVersion→skillVersion(or the version they asked to upgrade to). - Pull items from kit
CHANGELOG.md(repo root; if only the skill is installed, fetch changelog only for this upgrade). - 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.tskeys). - 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:
- Refresh the skill:
npx skills add github/qilei0529/bon-ui. - Copy kit source over
components/,lib/, and token CSS inapp/globals.css(see the repo README). - Merge
bon.config.ts: bumpversionto match the skill; keep the project’s palette / radius / spacing / typography values; add only new keys. - Keep
getBonConfigCss(bonConfig)+htmltheme attrs (data-palette,data-button-radius,data-panel-radius) andToastProvider,TooltipProvider,ThemeTransitionProvideron the root layout. - Re-run this gate. Write product UI only when the two versions match.