Skill
bon-ui component reference
skills/bon-ui/reference.mdImports use the @/ alias. Overlay/form parts are "use client". Unlisted props are native element or Base UI props.
Layout
AppLayout — @/components/base/app-layout
Sticky header + sidebar + main. Defaults nav from getNavForPath (@/lib/app-nav).
| Prop | Default | Notes |
|---|---|---|
headerNavItems | path-based | AppNavItem[] |
sidebarNavItems | path-based | AppNavEntry[] (items or { type: "group", label, items, defaultOpen? }) |
sidebarTitle | path-based | |
headerActions | <ThemeToggle /> | |
logoHref | "/" | |
headerHeight | "3.5rem" | CSS --app-header-height |
sidebarWidth | "14rem" | CSS --app-sidebar-width |
Page — @/components/element/page
<main> with px-page-x py-page-y gap-section-gap. maxWidth: none | sm | md | lg | xl | 2xl (default) | full. Note: "2xl" is max-w-2xl, not max-w-screen-2xl.
PageHead: page title row (<header>) with eyebrow (caption), title (left <h1>), description (lead, text-body, max-w-2xl), and children as right actions. Pulls up with -mt-3.
Section — @/components/ui/section
Unbordered <section>, flex flex-col gap-section-inner.
Panel — @/components/element/panel
Bordered card: rounded-panel border border-border p-panel gap-section-inner.
PanelHead: flush title row (<header>) with title (left <h2>) and children as right controls. Padding tracks --bon-panel-padding.
PanelFoot: flush footer (<footer>) with description left and children as right actions, bg-muted, gap-inline, mt-auto. Padding tracks --bon-panel-padding.
Grid — @/components/element/grid
grid grid-cols-12. gap: section (default, gap-section-gap) | inline | none.
GridCol span: 1–12 (default 12) or { default?, sm?, md?, lg?, xl?, 2xl? }.
TextStack — @/components/element/text-stack
Column; gap: tight | default | block | section.
Tabs — @/components/ui/tabs
Compound Base UI tabs. Prefixed names so TabsPanel does not collide with layout Panel.
TabsRoot defaultValue | value type="line"|"segmented" size="sm"|"md"
TabsList
TabsTab value="…" icon? iconPosition="left"|"right"
TabsIndicator
TabsPanel value="…"
Panel
type (default line): line | segmented. Line is the underline track + soft active wash; TabsIndicator is the bottom primary bar. Segmented is a muted pill track; TabsIndicator is the sliding solid primary background behind the active tab. size (default md): sm | md — scales tab padding, type, icon, and segmented track inset. orientation defaults to horizontal. TabsPanel does not keep hidden panels mounted unless keepMounted. TabsTab icon (optional) and iconPosition (left default | right). Icon-only: icon with no children (pass aria-label). Demo: /layout.
Step — @/components/ui/step
Wizard progress. Custom compound (Base UI has no Step). Statuses completed | current | upcoming derive from root value vs item order.
StepRoot orientation value | defaultValue onValueChange linear?
StepList
StepItem value="…"
StepIndicator
StepTitle
StepDescription?
StepSeparator? // omit on last item
orientation (default horizontal): horizontal | vertical. Horizontal lays out indicator + separator on one row, with title and description below. linear (default true): upcoming steps are not clickable. Completed and current steps select via click. value={undefined} / value="" (or omitted / no defaultValue) leaves every step upcoming — not started, first step is not current. Prefer "" when clearing a controlled value (undefined flips to uncontrolled). value={null} marks every step completed (finished wizard — last step included). StepIndicator shows the 1-based index, or CheckIcon when completed (children override). Pass aria-label on StepRoot when the group has no visible name. Parent owns wizard body content (no StepPanel in v1). Demo: /components.
CheckList — @/components/ui/checklist
Independent onboarding-style tasks. Custom compound. Statuses completed | pending derive from whether each item id is in root value (a string[] of completed ids, any order). error / progress on an item override.
CheckListRoot value | defaultValue onValueChange interactive? size?
CheckListList
CheckListItem value="…" error? progress?
CheckListIndicator
CheckListTitle
CheckListDescription?
size (default md): sm | md — scales indicator, status icon, title, and list gap. interactive (default true): click on the indicator or title toggles the item id in/out of value (description is not part of the hit target). Pass interactive={false} for display-only. Vertical only in v1 (no orientation). Statuses completed | pending | error | progress. CheckListIndicator shows the 1-based index, a solid green filled CheckIcon when completed, or outline (border-* / bg-background / semantic text) XIcon / CircleHalfIcon when error / progress (children override). Priority: error > progress > completed > pending. Completed titles use muted strikethrough; error titles use text-destructive; progress titles use text-info. Optional CheckListDescription sits under the title (text-caption text-muted-foreground; completed adds strikethrough + half opacity). Pass aria-label on CheckListRoot when the group has no visible name. Not a form Checkbox. Demo: /components.
Chrome primitives
| Component | Path | Role |
|---|---|---|
SidebarLayout / SidebarLayoutBody | base/sidebar-layout | Grid shell |
StickyHeader | base/sticky-header | Fixed header |
Sidebar | base/sidebar | Aside |
MainContainer | base/main-container | Scrollable main |
HeaderNav / HeaderNavItem | base/header-nav | Top links (items, active). Collapses overflow into a more menu. |
SidebarNav / SidebarNavItem | base/sidebar-nav | Side links (icon, active) |
SidebarNavGroup | base/sidebar-nav | Foldable section (label, defaultOpen, open, onOpenChange, containsActive) |
Logo | base/logo | Link; no children. Renders LogoMark plus the “bon-ui” wordmark. |
LogoMark | base/logo | Non-link “B” mark. Pass className to scale (size-8 default). |
Markdown | base/markdown | Skill / docs renderer |
Button — @/components/ui/button
variant (default primary): primary | outline | destructive | outline-destructive | gray | ghost.
size (default md): sm | md | lg. icon, iconPosition: left (default) | right. Icon-only: icon with no children.
Ripple on pointer down. render for non-button elements (Base UI).
ButtonGroup — @/components/ui/button-group
Fused action bar. Wraps existing Button children (shared edge, first/last rounded-button). No selection state.
orientation (default horizontal): horizontal | vertical. Non-first primary / gray children draw an ::after joining hairline. Outline / outline-destructive use the collapsed shared border only. Pass aria-label when the group has no visible name. Separate buttons stay flex … gap-inline.
<ButtonGroup aria-label="Text alignment">
<Button>Left</Button>
<Button>Center</Button>
<Button>Right</Button>
</ButtonGroup>
<ButtonGroup aria-label="Gray alignment">
<Button variant="gray">Left</Button>
<Button variant="gray">Center</Button>
<Button variant="gray">Right</Button>
</ButtonGroup>
<ButtonGroup aria-label="Outline alignment">
<Button variant="outline">Left</Button>
<Button variant="outline">Center</Button>
<Button variant="outline">Right</Button>
</ButtonGroup>
OverflowList — @/components/ui/overflow-list
Measures the row and keeps the prefix of items that fit. Remaining items go to renderOverflow. Pass renderOverflowTrigger so the more control is included in the measurement. isPinned keeps an item on the row by dropping the last unpinned visible item. className is the flex row (include the gap). Demo: /components (link row, separate buttons, fused outline group). HeaderNav uses this for the site links.
Each item is wrapped, so ButtonGroup selectors do not reach the buttons. Separate buttons: className="gap-inline". Fused outline group: no gap; overlap non-first wrappers with -ml-px and round the first and last button. A negative gap does not collapse the shared border. Wrap renderOverflow in a shrink-0 div so the more control joins that edge.
<OverflowList
items={items}
getItemKey={(item) => item.href}
isPinned={(item) => item.active}
renderItem={(item) => <HeaderNavItem href={item.href} active={item.active}>{item.label}</HeaderNavItem>}
renderOverflowTrigger={() => <button type="button" aria-label="More">…</button>}
renderOverflow={(hidden) => <MenuRoot>…</MenuRoot>}
/>
<OverflowList
className="gap-inline"
items={actions}
getItemKey={(item) => item.id}
isPinned={(item) => item.id === "delete"}
renderItem={(item) => <Button variant="outline" size="sm">{item.label}</Button>}
renderOverflowTrigger={() => <Button variant="outline" size="sm" icon={<MoreIcon />} aria-label="More actions" />}
renderOverflow={(hidden) => <MenuRoot>…</MenuRoot>}
/>
Toggle — @/components/ui/toggle
Icon-button style two-state control. Base UI pressed / defaultPressed / onPressedChange. Not Switch (form track) and not ButtonGroup (no selection).
variant (default outline): outline | ghost | gray | primary.
size (default md): sm | md | lg. icon optional. Icon-only: icon with no children — pass aria-label.
Pressed fills primary (data-pressed). primary variant inverts to muted when pressed. Demo: /components.
<Toggle icon={<SettingsIcon />} aria-label="Settings" />
<Toggle defaultPressed icon={<SettingsIcon />} aria-label="Pinned" />
<Toggle pressed={on} onPressedChange={setOn} icon={<SettingsIcon />}>
Pin
</Toggle>
Badge — @/components/ui/badge
variant (default primary): primary | muted | destructive | success | warning | info.
type (default soft): soft | outline. Soft uses a tinted fill; outline uses Button-like border-border with full semantic text, icon, and dot color (bg-current on the dot).
Leading indicator (priority: icon > check > dot):
icon— custom React node (status glyphs, etc.). Host forcessize-3+stroke-[1.75]on the SVG.check— built-in check markdot— status dot
Status demo (compose variant + icon + label): Pending (warning + CircleDotIcon), In Review (primary + CirclePauseIcon), In Progress (info + CircleHalfIcon), Not Started (muted + CircleDashedIcon), Cancelled (destructive + CircleXIcon), Approved (success + CircleCheckIcon). Demo: /components.
<Badge type="outline" variant="success" dot>Live</Badge>
Avatar — @/components/ui/avatar
Compound profile photo (AvatarRoot / AvatarImage / AvatarFallback). Base UI shows the fallback when the image is missing, loading, or fails.
size on Root (default md): sm | md | lg (size-6 / size-8 / size-10). Circular (rounded-full), muted surface. Fallback initials inherit text-button-*. Optional border (default off) adds border-button border-solid border-border.
<AvatarRoot size="md" border>
<AvatarImage src="/ava.jpg" alt="Ava Chen" />
<AvatarFallback>AC</AvatarFallback>
</AvatarRoot>
AvatarGroup
Overlapping stack of AvatarRoot children. size (default md) cascades to children (child size wins). max (default 4) caps visible faces; overflow renders a muted +N avatar. Pass max={0} (or any < 1) to show all. Ring uses ring-2 ring-background. Pass aria-label when the group has no visible name.
<AvatarGroup size="md" max={4} aria-label="Team">
<AvatarRoot>
<AvatarImage src="/ava.jpg" alt="Ava Chen" />
<AvatarFallback>AC</AvatarFallback>
</AvatarRoot>
<AvatarRoot>
<AvatarFallback>JD</AvatarFallback>
</AvatarRoot>
</AvatarGroup>
Demo: /components.
Notice — @/components/ui/notice
Inline banner (rounded-panel, border). Toast is for transient messages.
variant (default info): info | success | warning | error. Surfaces: bg-*/15 + border-*/50. Icon and title use text-info / text-success / text-warning / text-destructive. Tokens live on every palette (--bon-info, --bon-success, --bon-warning).
size (default md): md | lg. md uses tighter padding, body title, caption description. lg uses p-panel, section title, body description. Leading icon size/stroke are owned by the size map (size-4/stroke-[1.75] vs size-5.5/stroke-[1.5]). Built-in glyphs: @/components/ui/notice-icons (not shared Badge Circle* in @/components/icons).
title / description left stack. Children are right actions. onDismiss shows a ghost close; parent unmounts the notice.
role: status unless error (alert). Override with role.
Demo: /components. Page-chrome example: /layout.
Message — @/components/ui/message
Compound thread (MessageList / MessageRoot / MessageAvatar / MessageContent / MessageMeta / MessageAuthor / MessageTime / MessageBody / MessageStatus).
MessageList is role="log". maxHeight (default "24rem") caps the thread; overflow-y-auto scrolls the rest. Pass "none" to grow with content. The first paint jumps to the end. A taller thread then smooth-scrolls to the new message while the reader is already at the end (scrollTo behavior: "smooth"; instant when prefers-reduced-motion). Scrolling up leaves the position alone. MessageScroll sits inside the list: to="end" smooth-scrolls to the newest message and follows again; any other to is a MessageRoot id and scrolls that bubble into view without following the end. The outline button overlays the bottom center, outside the scroll flow, so showing it does not change scroll height. It hides while its target is already visible. MessageRoot side (default incoming): incoming | outgoing | agent. Incoming bubble is bg-muted text-foreground with an optional avatar on the left. Outgoing bubble is bg-primary text-primary-foreground and the row aligns to the end. agent is left-aligned plain text (text-body text-foreground) at full width (max-w-full): no background, rounded-panel, or bubble padding. Author and time use text-caption. Bubble padding is component-local; shape is rounded-panel. Meta inset (pl-3) and time margin (mr-3) apply to bubble sides only. MessageStatus stands in for 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", aria-busy="true", and aria-live="polite".
<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>
Demo: /chat.
Composer — @/components/ui/composer
Bordered stack (rounded-panel): borderless ComposerInput on top, ComposerFoot below. The foot is a mode menu on the left (ComposerMode, default Agent / Ask) and ComposerActions on the right (ComposerAttach plus, ComposerSubmit round arrow).
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. Optional value / defaultValue / onValueChange for a controlled field. disabled on the root blocks submit.
<ComposerRoot onSubmit={(text) => append(text)}>
<ComposerInput placeholder="Tell AI what changes to make — @ to reference media" />
<ComposerFoot>
<ComposerMode />
<ComposerActions>
<ComposerAttach />
<ComposerSubmit />
</ComposerActions>
</ComposerFoot>
</ComposerRoot>
Demo: /chat.
Loading — @/components/ui/loading
Centered status block for a panel or dialog body. Stack is icon → LoaderIcon → copy.
| Prop | Default | Notes |
|---|---|---|
icon | — | Context glyph. Omit for spinner + copy only. |
children | — | Caption line (text-caption, muted). |
role | "status" | Also aria-live="polite" and aria-busy="true". |
Fills leftover height (flex-1, min-h-40). Keep host chrome outside the block. Demo: /components.
Progress — @/components/ui/progress
ProgressRoot value={n} | value={null} (indeterminate)
ProgressLabel + ProgressValue size="sm"|"md"
ProgressHint tone="success"|"muted"|"destructive" // optional trend / comparison
ProgressTrack size="xs"|"sm"|"md" variant="bar"|"segments" segments={n}
ProgressIndicator // bar only
Default track variant="bar": continuous track + indicator use rounded-button. variant="segments": discrete cells (rounded-control); no ProgressIndicator needed. Fill count is round((value - min) / (max - min) * segments) (default segments={12}). Indeterminate segments are all empty + pulse. Track size: xs (h-1.5, table cells) | sm | md (default). ProgressValue size="md" is the prominent display number; default sm stays caption/muted. Demo: /components, table cell: /table.
Kbd — @/components/ui/kbd
Native <kbd> keycap. One style. size (default md): sm | md. KbdGroup wraps combos (inline-flex, tight gap). Pass separator to insert + between keys (separator="then" for a custom mark).
<Kbd>⌘</Kbd>
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</KbdGroup>
<KbdGroup separator>
<Kbd>⌘</Kbd>
<Kbd>Enter</Kbd>
</KbdGroup>
Inside TooltipPopup, keys invert automatically. Keep MenuShortcut for menu rows.
Form
FormRoot — @/components/ui/form
Native <form> that collects named FieldRoot values. onFormSubmit(formValues) (prevents default). Optional errors (keyed by field name), validationMode (onSubmit | onBlur | onChange), actionsRef (validate(fieldName?)). Types: FormRootProps, FormActions. Wrap FieldGroup or mixed Detail+Field; Detail is display-only. Demo: /form → Form tab.
Input — @/components/ui/input
size: sm | md (default). Invalid styling via Field invalid → data-invalid.
Textarea — @/components/ui/textarea
size: sm | md (default). rows default 3. Invalid styling via Field invalid → data-invalid. Vertical resize.
Field — @/components/ui/field
FieldRoot (name, invalid, disabled), FieldLabel (required asterisk icon / optional text; required wins if both), FieldDescription, FieldError (match for always-visible error in demos).
FieldGroup — @/components/ui/field-group
FieldGroupRoot variant?
FieldGroupSection title description?
FieldGroup columns? gap?
FieldRoot…
FieldGroupDivider?
FieldGroupRoot variant: default | gray (bg-muted/70 + padding + rounded-button). columns: 1 (default) | 2 | 3 | 4 — equal cols; stacks below md. gap: section (default) | inline. Base UI Fieldset; section title is the legend.
Detail — @/components/ui/detail
DetailRoot (orientation: vertical default | horizontal), DetailLabel, DetailValue. Empty value → em dash.
DetailGroup — @/components/ui/detail-group
DetailGroupRoot variant?
DetailGroupSection title description?
DetailGroup columns? gap?
DetailRoot…
DetailGroupDivider?
Mirrors FieldGroup visually (variant default | gray, columns 1–4) but uses section + h3 (not a fieldset). Demo: /form.
Stat — @/components/ui/stat
StatRoot, StatHead, StatHint, StatAction, StatMetric, StatValue, StatLabel, StatChange. Card shell uses panel border/radius/padding. StatHead lays out hint left + StatAction right; bare StatHint still works alone. StatChange direction: up | down | none; badgeVariant default warning. Omit value for muted text-only footer. Compose NumberValue inside StatValue when needed.
StatGroup — @/components/ui/stat-group
StatGroupRoot variant?
StatGroupSection title description?
StatGroup columns? gap?
StatRoot…
StatGroupDivider?
Mirrors DetailGroup (variant default | gray, columns 1–4). Demo: /layout.
Switch — @/components/ui/switch
Switch + SwitchItem (label wrapper). Track and thumb use rounded-button.
Checkbox — @/components/ui/checkbox
variant: solid (default) | outline. indeterminate. CheckboxGroup, CheckboxItem. Uses rounded-control (min(button-radius, 6px)).
Radio — @/components/ui/radio
RadioGroup, Radio, RadioItem. Root and inner indicator use rounded-button.
Select — @/components/ui/select
Stack:
SelectRoot
SelectTrigger size="sm"|"md"
SelectValue
SelectIcon
SelectPortal
SelectPositioner
SelectPopup
SelectList
SelectGroup / SelectGroupLabel
SelectSeparator
SelectItem
SelectItemIndicator
SelectItemText
SelectTrigger size: sm | md (default). Dropdown items always use Menu item type size.
Slider — @/components/ui/slider
SliderRoot marks min max step
SliderLabel + SliderValue
SliderControl
SliderTrack size="sm"|"md"
SliderIndicator
SliderThumb size="sm"|"md"
marks draws ticks from min to max at each step. The thumb snaps to those values. Track, indicator, thumb, and marks use rounded-button.
Calendar — @/components/ui/calendar
Month grid. Base UI does not ship Calendar; this primitive uses native Date / Intl.
CalendarRoot value defaultValue onValueChange visibleMonth locale isDateDisabled highlights renderDay renderAction fixedWeeks size disabled
CalendarHeader
CalendarTitle
CalendarToday
CalendarPrev
CalendarNext
CalendarGrid
CalendarWeekdays
CalendarDays
size: sm | md (default) | lg. sm uses size-7 day cells and an abbreviated month (Sep 2026); md uses the full month name. lg is a content board (w-full min-w-lg) of borderless bg-muted cells: the day number is a top-left selection button, renderAction is an optional top-right slot (omit it, or return null, to leave the corner empty), and renderDay is the cell body. Both callbacks are ignored unless size is "lg". Selected sm / md days keep a solid bg-primary cell; on lg that fill is the day chip. CalendarToday sits left of the month arrows (calendar icon, aria-label="Today") only while the visible month is not the current one. It shows the current month and focuses today without changing the selected date. Or use Calendar for the default anatomy.
highlights: Record<YYYY-MM-DD, { level?: 1|2|3; dot?: boolean }>. Primary wash alphas /15 /30 /50; optional indicator dot under the day (sm / md) or beside it (lg). Both can combine.
CalendarPicker — select-like trigger + popover grid. Default trigger: leading CalendarIcon, date/placeholder, trailing chevron. Trigger size: sm | md (default). The popup calendar stays md unless you compose a custom popup. Forwards highlights and isDateDisabled. Huge mode is not a picker size.
Demo: /calendar.
Chart — @/components/ui/chart
ChartRoot
type="bar"|"line"|"combo"|"grid"
data=[{ label, value? , color? , colors? , dashed? , dashes? , …seriesKeys }]
series?=[{ key, label, color?, type?, yAxis? }]
color?="primary"|… // single-series only
stacking?="grouped"|"stacked" // bar / combo bars
curve? // line / combo lines / grid area edge
fill? // line / combo lines
lineSize? // line / combo lines, stroke px
dotSize? // line / combo lines, px; 0 hides
pointAlign?="center"|"edge" // line / combo lines / grid sample x
gridRows? // grid cell rows, default 20
gridCols? // grid cell cols, default 48
gridSize? // square cell px, default 8
gridGap? // cell gap px, default 2
showYAxis?=boolean|{ left?, right? }
emptyVariant?="panel"|"plot"
aria-label barSize? barGap? seriesGap? showValue? showLabel? labelInterval? className?
Custom compound (no chart library). Single-series via datum.value, or multi-series when series is set (read datum[series.key]).
bar: vertical columns.stackingdefault"grouped"(side-by-side);"stacked"stacks segments (scale uses category sums;seriesGapignored).barSize(px, default24),barGap(default6),seriesGap(default4).line: SVG polyline + HTML markers;stacking/barSize/barGap/seriesGapignored.curve(defaultfalse) draws a smooth Catmull-Rom → cubic Bezier path instead of straight segments.fill(defaultfalse) adds a closed area under the line with a vertical gradient (series colorstopOpacity0.5 → 0).lineSize(px, default2) is stroke width (vectorEffect="non-scaling-stroke").dotSize(px, default8) is marker diameter;0hides markers.pointAligndefault"center"(slot midpoints, matches labels);"edge"places first/last on the plot edges with even spacing between, and positions category labels on the same x coordinates (first left-aligned, last right-aligned, middle centered). Stroke uses the same series color tokens.dashedon a single-series point dashes the segment that arrives at that point (previous point is the join). Multi-series / combo usedashes[seriesKey]instead;dashedis ignored when more than one series is set. Consecutive dashed points share one dashed run. Area fill stays a solid gradient.combo: bars and lines on one plot with shared category labels. Requiresserieswith at least onetype: "bar"and onetype: "line". Per-seriesyAxis"left"|"right"(default bar→left, line→right); independentniceMaxscales per side. Bar props apply to bar series; line props to line series.pointAligndefault"center"so lines align with bar slots. Missing bar or line series renders the empty placeholder.grid: single-series square cell field. Plot width/height are derived fromgridSize(default8) andgridGap(default2) withgridRows×gridCols(defaults20×48):extent = n * size + (n - 1) * gap. Samples the area under the series line at each column center (samepointAlign/ optionalcurveas line) and highlights cells covered by that area (bg-mutedotherwise). Y-axis uses the same fixed plot height so tick positions align with the matrix. Multi-series / stacking / bar size props ignored (only the firstserieskey is used whenseriesis set). Horizontal rule grid is not drawn. Y-axis defaults on.
showYAxis: true shows tick labels on sides that have series; false hides; { left?, right? } overrides per side (scale still used when hidden). Combo and grid default to showing axes; pure bar/line default off (opt in with showYAxis).
emptyVariant: "panel" (default) muted bordered placeholder; "plot" draws the same horizontal grid as a filled chart (4 rules + baseline) with centered “No Data”, and reserves legend / Y-axis / category-label slots from series / showYAxis / showLabel so layout does not jump when data loads.
showValue renders text-caption numbers above each bar (stacked: category sum) or each line point. showLabel (default true) shows category labels under the plot; set false to hide. labelInterval (default 1) shows every Nth category label (index % N === 0); first and last are always kept when labels are shown. Values < 1 are treated as 1. color (default "primary") applies when series is omitted; ignored when series is set (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 (Partial<Record<seriesKey, ChartSeriesColor>>) overrides per series on multi-series / combo bars and line markers without affecting other series at the same category (line stroke/fill stay series-colored). Per-datum dashed (single-series line) dashes the incoming segment; dashes does the same per series on multi-series / combo lines. Series color: primary | muted | gray | info | success | warning (index-cycles when omitted). Legend when series.length > 1 (not for grid). Default height h-56; override via className. Empty data uses emptyVariant. aria-label is required (role="img").
Demo: /chart.
Overlays
Trigger pattern: <XTrigger render={<Button variant="outline" />}>Label</XTrigger>.
Menu — @/components/ui/menu
MenuRoot
MenuTrigger
MenuPortal
MenuPositioner
MenuPopup
MenuGroup / MenuGroupLabel
MenuItem variant="default"|"destructive" + MenuShortcut
MenuLinkItem
MenuSeparator
MenuCheckboxItem (+ MenuCheckboxItemIndicator)
MenuRadioGroup
MenuRadioItem (+ MenuRadioItemIndicator)
MenuSubmenuRoot
MenuSubmenuTrigger
MenuPortal → MenuPositioner side="right" → MenuPopup
Checkbox/radio items prepend a check indicator unless you pass the indicator part yourself.
Popover — @/components/ui/popover
PopoverRoot → PopoverTrigger → PopoverPortal → PopoverPositioner (sideOffset default 4) → PopoverPopup showClose? → PopoverArrow? / PopoverTitle / PopoverDescription / PopoverClose / PopoverCloseIcon.
Hover: openOnHover on PopoverTrigger. Do not put a default max-w-* on PopoverPopup (CalendarPicker shares it).
Tooltip — @/components/ui/tooltip
TooltipRoot → TooltipTrigger → TooltipPortal → TooltipPositioner (sideOffset default 4) → TooltipPopup → TooltipArrow?.
side: top (default) | bottom | left | right. align: start | center (default) | end.
TooltipProvider is in root layout (shared delay + instant handoff between adjacent tooltips). Do not mount a second provider per page.
Disabled controls ignore pointer events: TooltipTrigger render={<span className="inline-flex" />} around the disabled control.
Dialog — @/components/ui/dialog
DialogRoot → DialogTrigger → DialogPortal → DialogBackdrop + DialogViewport → DialogPopup showClose? → DialogHead (title / description / showClose) → DialogContent → DialogFoot (description left, children as right actions). Or DialogTitle / DialogDescription / DialogClose / DialogCloseIcon.
DialogHead is title + description only (no header action slot). Prefer DialogHead showClose over DialogPopup showClose — do not use both. The close sits in the corner and does not reserve a button column.
DialogContent is a layout <div> (maxHeight default "60vh", overflow-y-auto). DialogFoot flushes to the popup edge with -mx-4 -mb-4 because DialogPopup already has p-4. Keep Head / Foot outside Content so they do not scroll.
AlertDialog — @/components/ui/alert-dialog
Same stack (AlertDialog*). Does not dismiss on backdrop. AlertDialogPopup accepts unused showClose in types; playground does not use a corner close.
Drawer — @/components/ui/drawer
side on Viewport and Popup: bottom (default) | right. Match swipeDirection on Root ("right" for side panel). Chrome: DrawerHead and DrawerFoot as DrawerPopup siblings. DrawerHead: title / description / children / showClose, px-4 pt-4. Close sits in the popup corner like Dialog (absolute top-3 right-3) and is not a header action column; header uses pr-12 when showClose so title and actions clear the icon. DrawerFoot: description left, children as right actions, bg-muted/60. Inner body: DrawerContent (flex-1 min-h-0 overflow-y-auto) — scrolls when content exceeds the remaining height. Do not also pass showClose on DrawerPopup when the head already shows close.
Toast — @/components/ui/toast
ToastProvider is in root layout (includes viewport + list). App code: useToastManager() then add({ title, description?, type?, size?, actionProps? }).
Display matches Notice: leading status icon, solid bg-background, accent title, muted description, tinted dismiss. size (default md): md | lg — same map as Notice (statusSizes). Types: success | error | warning | loading | default (info). Shared accents/icons: @/components/ui/status-surface. With two or more open toasts, Clear all appears at the top-right only while the stack is expanded (hover or focus) and calls close() with no id.
Do not mount a second ToastProvider per page.
Table
NumberValue — @/components/ui/number-value
Display a number with an optional unit before or after. Compose inside TableCell or ColumnDef.cell (not a td itself). Empty / null / undefined → em dash.
| Prop | Default |
|---|---|
value | required (number | string | null | undefined) |
unit | — ("$" / "¥" / "%" …) |
unitPosition | "after" ("before" | "after") |
maximumFractionDigits | — (when set, formats numeric value via Intl.NumberFormat) |
color | "default" ("default" | "positive" | "negative" | "auto") |
Numbers use Intl.NumberFormat with grouping; strings render as-is. Unit uses text-muted-foreground when color is default; colored values tint number + unit together (positive → #2FBE85, negative → #F6475D). auto derives from the numeric sign (zero stays default). Demo: /table.
<NumberValue value={128} unit="$" unitPosition="before" />
<NumberValue value={12} unit="%" color="positive" />
<NumberValue value={-4} unit="%" color="negative" />
<NumberValue value={row.delta} unit="%" color="auto" />
EmailValue — @/components/ui/email-value
Display an email with the local-part emphasized and @domain muted. Compose inside TableCell, ColumnDef.cell, or DetailValue. Empty / null / undefined / blank → hyphen. No @ → whole string as the emphasized local part. Splits on the last @. Demo: /table.
| Prop | Default |
|---|---|
email | required (string | null | undefined) |
<EmailValue email="ava@example.com" />
<EmailValue email={row.original.email} />
<EmailValue email={null} />
Primitives — @/components/ui/table
Table variant: default | gray | bordered (border on every th / td; outer frame uses rounded-button so corners follow the button radius, not the panel). Plus TableHeader TableBody TableFooter TableRow TableHead TableCell TableCaption.
DataTable — @/components/element/data-table
TanStack Table. Columns: ColumnDef<T>[] from @tanstack/react-table.
size on a ColumnDef is a fixed pixel width (will not stretch to fill the table). Omit size so that column absorbs leftover space. Flexible columns honor minSize as a shrink floor (default 96 if omitted); when the wrap is narrower than the sum of fixed sizes + flex floors, the table scrolls horizontally (overflow-x-auto on the wrap). Use truncate in the cell for long content within the floor. If every column has size and the sum is narrower than the wrap, the table stays content-wide and the extra space sits on the right.
| Prop | Default |
|---|---|
columns / data | required |
filterPlaceholder | "Filter…" |
showFilter | true (set false to hide the built-in search) |
pageSizeOptions | [5, 10, 20] |
initialPageSize | 10 |
selectable | false |
rowSelection / onRowSelectionChange | uncontrolled if omitted |
getRowId | index |
variant | "default" ("gray" muted surface, "bordered" cell grid) |
columnPinning | { left, right } column ids; sticky while the middle scrolls |
Select-all applies to the current page. When columnPinning is set and selectable is on, the checkbox column is prepended to left.
Per-column sort is on by default. Set enableSorting: false on a ColumnDef for action / checkbox columns. DataTable already hides the sort control when column.getCanSort() is false.
DataTableToolbar — @/components/element/data-table-toolbar
Left/right toolbar above a table. Children sit on the left (typically FilterField). actions sit on the right.
| Prop | Default |
|---|---|
children | filter chips / left controls |
actions | right-side buttons |
onClear | clear handler |
showClear | false |
clearLabel | "Clear filters" |
FilterField — @/components/ui/filter-field
Dashed add chip when empty; applied chip (× Label | value ▾) when set. required hides × so the filter stays applied; the value can still change. Popover has a Select + Apply. Parent owns the applied value and must skip required filters in onClear.
| Prop | Default |
|---|---|
label | required |
options | string or { value, label? } |
value | null |
onValueChange | (value: string | null) => void |
required | false |
capitalize | false |
StatusFilter — @/components/ui/status-filter
Single-select control above a table. variant: line (default, underline strip) | button (outline row) | card (title, optional description, and count). Parent owns value and computes count from the rows before the status slice (after any other filters) so the numbers do not collapse to the visible page. Include an All option to show every row. Clicking the active item does not clear the selection. Demo: /table.
| Prop | Default |
|---|---|
options | { value, label, count, description? }[] required |
variant | "line" |
value | controlled selection |
defaultValue | first option when value is omitted |
onValueChange | (value: string) => void |
aria-label | required |
Theme
| Export | Path |
|---|---|
ThemeToggle useTheme useAnimatedTheme | @/components/theme-toggle |
ThemeTransitionProvider useThemeTransition | @/components/theme-transition |
applyTheme getStoredTheme Theme | @/lib/theme |
useThemeTransition().startTransition(event, { onUpdate }) — circular reveal. Palette stays on bon.config.ts; mode is localStorage.
Root must keep data-palette, data-button-radius, data-panel-radius from bonConfig and theme init scripts in app/layout.tsx. Prefs/mode only toggle class / data-*; colors and radii come from getBonConfigCss.
Icons — @/components/icons
PlusIcon CheckIcon XIcon CloseIcon ArrowRightIcon LoaderIcon ThemeAutoIcon ThemeDarkIcon ThemeLightIcon ChevronUpIcon ChevronDownIcon ChevronsUpDownIcon TypeIcon SpacingIcon LayoutIcon GridIcon ComponentsIcon TableIcon FormIcon SearchIcon SettingsIcon ChatIcon.
Badge status glyphs: CircleCheckIcon CircleXIcon CircleDotIcon CircleHalfIcon CircleDashedIcon CirclePauseIcon (compact, size-3). Notice status glyphs are separate: @/components/ui/notice-icons.
Prefer these over new SVGs for UI chrome. Size via className (size-3 / size-4).
Config snapshot
Edit bon.config.ts (satisfies BonConfig). Current repo values (may change): version 0.1.1; palette neutral; button radius lg, border sm; panel radius xl; spacing pageY: "6" (not README’s "16"). version is the kit stamp, not a CSS token.
Wired by getBonConfigCss in app/layout.tsx.