Skip to content

04 - Components

Try every component live → Open Storybook. This page is the spec (when, why, never); Storybook is the playground (props, states, code).

Planning a new primitive? Component Roadmap lists every primitive we plan to add (Timeline, DatePicker, Combobox, Command Palette, Stepper, Dropzone, OTPInput, …) with full specs and implementation checklists. Specs live there until shipped, then move into this page.

Every @na/ui component: what it is, when to use it, when NOT, and what NEVER to do.

Organized Ant-style: General · Layout · Navigation · Data Entry · Data Display · Feedback.

If a component you need isn’t here, it doesn’t exist yet. Stop and reconsider whether it should — see C8 Restraint in 01-principles.md — before adding a new primitive.

Every component imports from @na/ui/components/<name>. Full list in AGENTS.md Rule 1.


Composition contract — Radix UI primitives, vendored locally

@na/ui is not a wrapper around shadcn/ui. We use Radix UI directly as the headless / accessibility / behavior layer; styling lives in this repo as Tailwind classes on top of Radix’s data-state and data-slot selectors.

Specifically:

  • Radix is a runtime dependency. @radix-ui/react-dialog, @radix-ui/react-select, etc. ship with the apps. They handle focus traps, keyboard navigation, ARIA, escape semantics, portal rendering — everything that’s painful to get right by hand.
  • Tailwind is the styling layer. Every @na/ui component composes Radix primitives with Tailwind classes (via cn() and occasional cva).
  • shadcn is NOT a dependency. We do not run pnpm dlx shadcn add. Components are vendored as plain .tsx files in packages/ui/src/components/. Adding a new primitive means: pick the right Radix package, copy a minimal scaffold (look at dialog.tsx or popover.tsx for the shape), and write the Tailwind classes yourself per the design system tokens in 02-foundations.md.
  • Why no shadcn CLI? The CLI is convenient for bootstrapping a project but conflicts with our intent: own every component, every class, every token. Letting the CLI rewrite a component bypasses our design-spec review. Once you have a project, the CLI’s value drops to zero — you just need Radix + Tailwind.

Component design-doc structure. Every primitive section below follows roughly this shape (deeper for primitives users touch daily, lighter for utility ones):

  1. Import path
  2. Purpose — what it is and what user problem it solves
  3. Variants / sizes (where applicable)
  4. Anatomy — the parts and their roles
  5. States — default, hover, focus, disabled, error, loading (where applicable)
  6. Code example
  7. A11y notes — keyboard, ARIA, screen reader behavior
  8. Do / Don’t
  9. Cross-references to other primitives

If you’re adding a new primitive, write its doc in this shape before merging the code. A primitive without a design doc is a primitive that drifts.


General

Typography — Text, H1–H6

@na/ui/components/typography

Text primitives that encode the Ash design system. Use instead of raw HTML + Tailwind for any text element.

Headings — semantic HTML with design-system styles (semibold, tracking-tight):

ComponentHTMLSizeUse
<H1><h1>24pxPage title (rare in admin)
<H2><h2>20pxDetail page title
<H3><h3>16pxSection heading, page bar title
<H4><h4>14pxCard title, form section title
<H5><h5>12pxMinor heading
<H6><h6>12pxMinor heading

Text — body text with variants:

VariantSizeWeightUse
body14px400Default body text
muted14px400Descriptions, timestamps, metadata
label12px500Non-form labels, table headers
caption12px400Small metadata, helper text
import { H2, H3, H4, Text } from '@na/ui/components/typography';
// Section with description
<H3>System Variables</H3>
<Text variant="muted">Read-only environment variables.</Text>
// Detail header
<H2>{agent.name}</H2>
<Text variant="muted" as="p" className="line-clamp-3 max-w-3xl">
{agent.description}
</Text>
// Compose with Radix Slot — renders as Link but with Text styles
<Text variant="body" asChild>
<a href="/agents">View all</a>
</Text>

Do

  • Use <H3> for section titles, <Text variant="muted"> for descriptions.
  • Use className for layout-only overrides (truncate, max-w, line-clamp).
  • Use asChild for polymorphic composition with links or custom elements.

Don’t

  • Don’t write <h2 className="text-xl font-semibold tracking-tight"> — use <H2>.
  • Don’t use <span className="text-muted-foreground text-sm"> — use <Text variant="muted">.
  • Don’t use font-bold (700) — the primitives use font-semibold (600). See 02-foundations.md.

Button

@na/ui/components/button

The most-used component in the system. Primary interactive primitive.

Variants

VariantUse
defaultPrimary action (Save, Create, Submit)
outlineSecondary action (Cancel, Filter)
destructiveDelete, Remove, Irreversible
ghostRow actions, low-weight actions
linkInline link that looks like text

Sizes

SizeWhen to use
defaultForm submits inside ActionBar, dialog footer
smPage bar, toolbar, section header actions
xsDense toolbars, inline actions
iconSquare icon-only button
icon-sm32px icon-only
icon-xs24px icon-only — row actions

Tooltip props — built in.

<Button tooltip="Edit this row" size="icon-xs" variant="ghost">
<Pencil />
</Button>
<Button disabled disabledReason="Fill required fields first">Save</Button>
  • Icon-only buttons must have tooltip (the label is invisible otherwise).
  • Disabled buttons with a reason must use disabledReason (users deserve to know why).
  • Don’t add tooltip that restates the label — that’s noise.

Do

  • Use one primary action per page bar.
  • Use outline for Cancel, Reset, secondary filters.
  • Use destructive only for irreversible actions.
  • Use ghost icon-xs for per-row actions.

Don’t

  • Don’t build a button from <div> + Tailwind. See AGENTS.md Rule 2.
  • Don’t use font-bold on button text — button comes with proper weight.
  • Don’t override padding, height, or cursor — the variants handle it.
  • Don’t stack primary buttons side-by-side. One primary per action group.

Badge

@na/ui/components/badge

Small label for status, tags, counts.

Variants: default · secondary · outline · destructive

Do

  • Use for “status” chips (Active, Draft, Live).
  • Use for count indicators (3 new messages → <Badge>3</Badge>).
  • Use for tags — variant="outline" for metadata tags.

Don’t

  • Don’t use a Badge for a button — if it’s clickable, it’s a Button.
  • Don’t wrap Badge in a link — use a Button with variant="link" instead.
  • Don’t apply custom background colors — use variants.
  • Don’t render more than ~24 characters inside a Badge — it’s a chip, not a sentence. Long values should live in a <PropertyRow> or <Tooltip> with the full string.

Truncation. When a backend returns a label that may exceed 24 chars (status strings, user-defined tags), set max-w and let the badge truncate with the full value in title:

<Badge
variant="secondary"
className="max-w-[180px] truncate"
title={tag}
>
{tag}
</Badge>

StatusBadge

@na/ui/components/StatusBadge

Opinionated badge with colored dot for status. Use instead of raw Badge when conveying on/off, healthy/degraded, etc.


Layout

Most layout components are documented in 03-layout.md. One-line summaries here for the index.

ComponentPurpose
ListPageBarTop bar for List + Form page archetypes
DetailPageBarTop bar for Detail archetype (back + tabs)
PageHeaderReusable two-line header (title + tabs/search)
PageContentThe only scroll zone; narrow / spaced props
ContentSectionTitled content group with actions slot
ContentToolbarLeft filters / right actions inside content
SplitContentTwo-column layout with sidebar
SplitScrollContentSplit layout where each pane scrolls independently
TwoColumnLayoutFull-page two-column (agent detail split)
CardGridResponsive 2/3/4-column card grid
StatRowHorizontal KPI row with dividers
FormSectionTitled form-field group with space-y-4
ActionBarSticky bottom bar for Save/Cancel — sibling of PageContent
DetailHeaderEntity identity card (avatar + name + metadata)
SeparatorThe way you separate flat sections — mandatory between <FormSection> groups and between <CollapsibleSection>s in settings / right-panel. Replaces <Card> boundaries in the Attio flat-first model.
RightPanelFlat right-side detail panel for 3a Split archetype (Plan 2)
PropertyList / PropertyRow / InlineEditFieldFlat key-value rows with click-to-edit (Plan 2)
CollapsibleSectionTitled section with ▾ chevron, persisted per-user state (Plan 2)
ViewSwitcherTable / Kanban / Calendar switcher — task/flow screens only (Plan 2)
FilterChipsPill-style filter chips above a collection (Plan 2)
HoverCardPreviewAnchored popover on entity-name hover (Plan 2)
InboxListInbox archetype: list + preview with j/k/e triage (Plan 2)
BoardViewKanban columns + drag-drop + multi-select (Plan 2)
FocusLayout / StepperFocus archetype shell for wizards (Plan 2)

Tabs

@na/ui/components/tabs

Used inside DetailPageBar (L3 navigation) or inline content switching.

Do

  • Use for ≤ 8 sub-sections of a detail page.
  • Keep tab labels short (1-2 words).
  • Keep tabs text-only — no icons in L3 tabs.

Don’t

  • Don’t use tabs for primary navigation (that’s the sidebar).
  • Don’t put tabs inside tabs (fourth nav tier — forbidden).
  • Don’t put tabs inside the sidebar.

@na/ui/components/sidebar

The L2 app navigation. Collapsible (Cmd+B to toggle).

Do

  • Use one entry per top-level section of the app.
  • Group related entries with SidebarGroup + label.
  • Put user avatar in SidebarFooter for single-app users.

Don’t

  • Don’t add Save buttons to the sidebar.
  • Don’t nest sub-sections — use tabs on the detail page instead.
  • Don’t put business logic in the sidebar component.

Sheet

@na/ui/components/sheet

Slide-out drawer. Used for: mobile sidebar substitute, side panels, forms that need more room than a Dialog.

Do

  • Use when a form has 5+ fields and you don’t want to navigate away.
  • Use for mobile sidebar (rail + sidebar collapse to a Sheet below md).
  • Provide explicit close button.

Don’t

  • Don’t use Sheet for confirmations — use <ConfirmPopover> / <ConfirmDialog>.
  • Don’t use Sheet for navigation — that’s the sidebar.

Content layout inside <SheetContent> (the padding/alignment gotcha)

SheetContent is a flex flex-col gap-4 container with no horizontal padding of its own. Its convention-supported children (SheetHeader, SheetFooter) provide their own internal p-4. Any custom content inserted between them MUST provide its own px-4 (or full p-4) to keep left/right edges aligned with the header and footer.

// WRONG — buttons touch the drawer edges, border-b extends edge to edge,
// misaligns with SheetHeader's internal p-4.
<SheetContent>
<SheetHeader>...</SheetHeader>
<div className="flex items-center gap-2 border-b pb-3">
<Button>Edit</Button>
<Button>Re-run</Button>
</div>
</SheetContent>
// CORRECT — px-4 matches SheetHeader's internal padding. Border-b sits within the
// visual content column. No manual mt-* needed: SheetContent's `gap-4` spaces siblings.
<SheetContent>
<SheetHeader>...</SheetHeader>
<div className="flex items-center gap-2 border-b px-4 pb-4">
<Button>Edit</Button>
<Button>Re-run</Button>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="px-4 pb-4">...</div>
</ScrollArea>
</SheetContent>

Rules:

  • Every custom child of <SheetContent> needs its own horizontal padding (px-4 minimum). Vertical padding is already provided by parent’s gap-4, so avoid mt-* on siblings — it stacks on top of the gap and inflates spacing.
  • For a scrollable body region, use min-h-0 flex-1 on <ScrollArea> (not fixed heights like h-[calc(100vh-8rem)] — those break when the action bar height changes, and collapse the drawer on short viewports).
  • A border-b on a mid-drawer divider must sit inside a px-4 container, not edge-to-edge.
  • Only <SheetHeader> and <SheetFooter> provide internal padding for you. Custom divs don’t.

This is the #1 visual bug when adding an action bar to an existing drawer.

@na/ui/components/dropdown-menu

Menu of actions triggered by a button. Use for row-action overflow when > 3 inline icons.

Do

  • Use <MoreHorizontal /> as the trigger icon.
  • Group related items with <DropdownMenuGroup> + <DropdownMenuSeparator>.
  • Use <DropdownMenuItem onClick={...}> for each action.

Don’t

  • Don’t use for > 8 items — it’s a menu, not a list.
  • Don’t put destructive actions at the top — put them at the bottom with a separator.

@na/ui/components/breadcrumb

Forbidden in our apps. Shell provides L1 + L2 navigation; DetailPageBar provides back button. Breadcrumbs are a fourth navigation tier that we explicitly reject.

Present in the library only because some exported stories still reference it. Delete usage when you find it.

TablePagination

@na/ui/components/TablePagination

Compact pagination footer for tables. Attio-minimal: count + page-size + chevrons. No numbered page links1, 2, 3, …, 8, 9, 10 is busy and rarely used; chevrons + filters/search cover real use cases.

Anatomy

┌─ border-t, h-10, px-3 ────────────────────────────────────────────────┐
│ 1–25 of 127 Rows per page [25 ▾] [◀] [▶] │
│ ↑ count ↑ optional page-size ↑ chevrons │
│ muted (h-7, text-xs) (icon-xs │
│ range bolded ghost, │
│ tooltip) │
└──────────────────────────────────────────────────────────────────────┘

Props

PropTypeNotes
pagenumber0-based page index
pageSizenumberItems per page
totalElementsnumberTotal items across all pages
totalPagesnumberTotal pages
onPageChange(page: number) => voidCalled on chevron click (0-based)
onPageSizeChange?(size: number) => voidOptional. When omitted, the page-size Select is hidden.
pageSizeOptions?number[]Defaults to [10, 20, 50].

States

StateAppearance
empty”No results” replaces the count
at first pagePrevious chevron is disabled (opacity-50 cursor-not-allowed)
at last pageNext chevron is disabled
count visibleRange numbers bold (text-foreground font-medium tabular-nums); “of N” muted

Code

<TablePagination
page={data.number}
pageSize={data.size}
totalElements={data.totalElements}
totalPages={data.totalPages}
onPageChange={setPage}
onPageSizeChange={setPageSize} // omit to hide the Select
pageSizeOptions={[10, 20, 50]}
/>

A11y

  • Outer <nav role="navigation" aria-label="Pagination">.
  • Chevron buttons have tooltip="Previous page" / "Next page" (renders aria-label + visible tooltip).
  • Disabled chevrons are real <button disabled> — focus skips them.

Why no numbered page links

The previous design rendered 1, 2, 3, …, 8, 9, 10 page-number buttons with full-width “Previous” / “Next” labels. Removed because:

  • For small N (≤10 pages): chevrons are 9 clicks max; numbered links are visual noise.
  • For large N (50+ pages): page numbers don’t help — you can’t visually pick “page 23” out of 1, 2, …, 23, 24, 25, …, 50. Filters and search are the right tool.
  • Attio’s collection tables (Companies, People, Deals — confirmed in references/attio-layout-patterns.md) paginate with chevrons + count, no numbered links.

If a screen genuinely needs jump-to-page, add a quiet Page [3] of 12 indicator with the bracketed number being a typeable input — cleaner than 10 buttons. Not yet implemented; add as <TablePagination jumpable /> when a real screen needs it.

Do

  • Use <TablePagination> for any table backed by a paginated API (server-side pagination).
  • Always show the count 1–25 of 127. Numbers are scannable, position is not.
  • Default page size: 25 (per 05-patterns.md § Pagination).
  • Pair with a server query that uses page + size params.

Don’t

  • Don’t show TablePagination when totalPages <= 1 — the chevrons would always be disabled.
  • Don’t use infinite scroll on admin tables — pagination is more predictable, scroll memory isn’t.
  • Don’t add a per-screen page-size dropdown that diverges from [10, 20, 50] — consistency across the app.
  • Don’t hand-roll a chevron-pagination div — use <TablePagination>. The disabled-state focus-skipping, tooltip a11y, and tabular-nums on the count are all in the primitive.

Bare Pagination primitive retired (2026-04-28). The lower-level Pagination / PaginationContent / PaginationLink / PaginationPrevious / PaginationNext / PaginationEllipsis exports were removed because they had zero consumers outside <TablePagination>. If a future surface needs custom pagination, build it from <Button> directly or re-vendor the bare primitives from Radix at that point. Same precedent as <DetailHeader> and <Toggle>.


Data Entry

Input / password-input

@na/ui/components/input / password-input

Single-line text input. The default form-control primitive.

Anatomy

[Label] * ← optional <Label>; * marks required
[ ____________________ ] ← <Input> — h-9, border, rounded-md
Help text below. ← optional <p className="text-muted-foreground text-xs">

States

StateAppearance
defaultborder-border border, bg-background, text-foreground
hoverunchanged (text inputs don’t visually change on hover)
focusring-2 ring-ring ring-offset-2, border stays
disabledopacity-50 cursor-not-allowed (via the input’s disabled attr)
errorborder-destructive + <FormMessage> below
readonlysame as default; no focus ring; cursor stays text

Sizes. One canonical height: h-9 (36px). Resist the urge to add sm or lg. Density inside Settings comes from spacing, not from shrinking inputs.

Code

// Plain
<div className="space-y-1.5">
<Label htmlFor="email">Email *</Label>
<Input id="email" type="email" required />
<p className="text-muted-foreground text-xs">Used for sign-in and notifications.</p>
</div>
// Password (include reveal toggle)
<PasswordInput id="password" autoComplete="current-password" />
// Number (right-align via tabular-nums in cell context only)
<Input type="number" inputMode="numeric" />

A11y

  • <Label htmlFor> MUST match the input’s id. Implicit <Label>{<Input/>}</Label> works too.
  • aria-required="true" on required inputs (the * suffix is visual only).
  • aria-invalid="true" on error states (auto-set by <FormField> when validation fails).
  • aria-describedby linking to the help-text <p id> so screen readers read it.

Do

  • Always pair with a <Label> (explicit or via <FormField>).
  • Use password-input for passwords — it includes show/hide toggle and is keyboard-accessible.
  • Use type="email", type="url", type="number", type="tel" when applicable for mobile keyboards.
  • Use autoComplete correctly — email, current-password, new-password, one-time-code.

Don’t

  • Don’t use <input> directly — you’ll miss focus ring, border, sizing, and disabled cursor.
  • Don’t put help text inside the input’s placeholder — placeholder disappears when typing.
  • Don’t use a long placeholder as your label. Labels are always visible.
  • Don’t add font-bold or override the input’s font weight. The body weight (500) is correct.

Textarea

@na/ui/components/textarea

Multi-line text input. Use for descriptions, notes, addresses, and any free-form content that wraps.

Anatomy

[Label]
┌────────────────────────────┐
│ │
│ │ ← <Textarea rows={4}>, min-h-16
│ │
└────────────────────────────┘
Help text.

States. Same as Input, plus:

StateAppearance
resizeresize-y by default (vertical drag handle, bottom-right)
max-hIf long content: cap with max-h-[20rem] + internal overflow-y-auto; never silently clip

Code

<div className="space-y-1.5">
<Label htmlFor="bio">Bio</Label>
<Textarea
id="bio"
rows={4}
placeholder="A short description of this agent"
/>
<p className="text-muted-foreground text-xs">Visible on the agent card.</p>
</div>

A11y

  • Same as Input: htmlForid pairing, aria-required, aria-invalid, aria-describedby.
  • For long-form fields (1000+ chars), expose a live character count via aria-live="polite".

Do

  • Set initial height via rows={4} (sensible default; ~96px). Larger for prompts (8-12).
  • Auto-grow only when content length varies wildly (descriptions, prompts) — use rows={1} + field-sizing-content (CSS) or a controlled height.
  • For prompts and rich content, escalate to <PromptEditor> (Monaco) — Textarea is for plain text.

Don’t

  • Don’t use Textarea for a single-line field (cramped even on desktop).
  • Don’t set a fixed max-height without scroll — content gets clipped silently.
  • Don’t disable resize unless the layout truly forbids it (e.g., chrome-fixed surfaces).

Label

@na/ui/components/label

The accessible label for every form control. Wraps Radix’s <LabelPrimitive.Root>.

Anatomy

[Label text] * ← * means required (visual only; aria handles it)

Sizing + weight. Per 02-foundations § Typography:

VariantClassUse
Defaulttext-sm font-mediumForm labels next to Switch/Checkbox/Slider
Stacked above an inputtext-sm font-mediumMost form rows
Stacked above a Select (Attio)text-muted-foreground text-xs font-mediumPer 13-section-anatomy § S5 Select row
Section sub-heading inside a rowtext-xs font-medium uppercase tracking-wideRare — group label inside a complex setting

States

StateAppearance
defaulttext-foreground
disabledopacity-50 (inherited via group-data-[disabled=true])

Code

<Label htmlFor="email">Email *</Label>
// Implicit (input nested inside label) — works without htmlFor
<Label>
Show advanced
<Switch className="ml-2" checked={} onCheckedChange={} />
</Label>

A11y

  • Use htmlFor matching the control’s id, OR nest the control inside <Label>.
  • Required labels suffix with * AND set aria-required="true" on the control. Never rely on the visual * alone.
  • Don’t put a <Label> next to a non-form-control element (e.g., a section heading) — use <H3> / <H4> for those.

Do

  • Sentence case (“First name”), not title case (“First Name”).
  • No trailing colon or period.
  • One Label per form control.

Don’t

  • Don’t use Label as a section heading (use typography primitives).
  • Don’t put a <Label> without an associated control.
  • Don’t use font-boldfont-medium is the spec weight.

Select

@na/ui/components/select

Dropdown for 1-of-N choice. Wraps Radix Select.

Anatomy

SelectTrigger [Selected value ▾] ← h-9, looks like Input
▼ (on click)
SelectContent ┌────────────────────────┐
│ SelectGroup │
│ SelectLabel ← muted │ ← group header (text-xs muted)
│ SelectItem │
│ SelectItem ✓ │ ← selected (check left)
│ SelectSeparator │ ← optional divider
│ SelectItem │
└────────────────────────┘

Parts

PartClass anchorPurpose
SelectTriggerh-9 rounded-md border bg-backgroundThe closed-state input-like surface
SelectValueinlineRenders the selected value or placeholder
SelectContentbg-popover border shadow-md max-h-[var(--radix-select-content-available-height)]The portal-rendered options panel
SelectItemtext-sm + hover/selected bgOne option
SelectGroupcontainerLogical grouping
SelectLabeltext-muted-foreground text-xs font-mediumGroup section header — quiet, matches DropdownMenuLabel
SelectSeparatorbg-border h-pxDivider between groups

States

StateAppearance
defaultborder-border bg-background
hover (item)bg-accent text-accent-foreground
focusring-2 ring-ring
selected (item)trailing <Check> icon, no special bg
disabled (item)opacity-50 pointer-events-none
opentrigger gets data-[state=open]:; chevron rotates

Code

// Stacked Settings row (S5 pattern)
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">Language</Label>
<Select value={language} onValueChange={setLanguage}>
<SelectTrigger>
<SelectValue placeholder="Choose a language" />
</SelectTrigger>
<SelectContent>
<SelectItem value="vi-VN">Vietnamese (vi-VN)</SelectItem>
<SelectItem value="en-US">English (en-US)</SelectItem>
</SelectContent>
</Select>
</div>
// Grouped (e.g., voices by provider)
<SelectContent>
<SelectGroup>
<SelectLabel>Google</SelectLabel>
<SelectItem value="vi-VN-Standard-A">Standard A</SelectItem>
<SelectItem value="vi-VN-Wavenet-B">Wavenet B</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>OpenAI</SelectLabel>
<SelectItem value="alloy">Alloy</SelectItem>
</SelectGroup>
</SelectContent>

A11y

  • Radix handles ARIA: role="combobox", aria-expanded, aria-controls. You don’t need to add them.
  • Keyboard: Space/Enter open, ↑↓ navigate, Esc close, type-ahead jumps to matching item.
  • The trigger MUST have an accessible name — wrap with <Label htmlFor> or include aria-label if the label is implicit (e.g., a toolbar select).

Max height + search threshold

Content caps at max-h-[var(--radix-select-content-available-height)] with internal overflow-scroll (Radix default). When the option list exceeds 8 items, switch to a <Combobox> (filterable cmdk-style dropdown) — users shouldn’t scroll a Select to find “Vietnam”.

Do

  • Use for 4+ options. Below 4, use RadioGroup.
  • Use for long lists (country, timezone) with a search if > 20 items.
  • Default to a sensible value — “Select…” is a last resort.
  • Use SelectLabel for group headers — keep weight at font-medium (500), not font-semibold.

Don’t

  • Don’t use Select for a yes/no choice — use <Switch> or <Checkbox>.
  • Don’t use Select for 2-3 options — use <RadioGroup> so all options are visible.
  • Don’t put a free-text fallback (“Other…”) that opens a separate input — use a <Combobox> with creatable instead.

Checkbox

@na/ui/components/checkbox

Boolean toggle, or one of a multi-select group. Wraps Radix Checkbox.

Anatomy

☐ Label ← checkbox + Label, gap-2
✓ ← checked state shows a Check icon inside the box

States

StateAppearance
defaultborder-border 16px square
hoverunchanged (no per-control hover; click target is the whole row)
focusring-2 ring-ring ring-offset-2
checkedbg-primary fill + <Check> icon
indeterminatedash icon (for “select all” headers when partial)
disabledopacity-50 cursor-not-allowed

Code

<div className="flex items-center gap-2">
<Checkbox
id="tos"
checked={agreed}
onCheckedChange={setAgreed}
/>
<Label htmlFor="tos">I agree to the terms</Label>
</div>;
// Multi-select in a list (controlled)
{
tools.map((t) => (
<Checkbox
key={t.id}
checked={selected.has(t.id)}
onCheckedChange={(v) => toggleSelected(t.id, !!v)}
/>
));
}

A11y

  • Always pair with <Label htmlFor>. Click on label toggles the checkbox.
  • For “select all” headers, use the indeterminate state when partial.
  • Group multiple related checkboxes inside <fieldset> + <legend> for screen-reader grouping.

Do

  • Use for multi-select (select multiple tools, multiple tags).
  • Use for terms-and-conditions style single confirmation.
  • Use indeterminate for “select all” with partial selection.

Don’t

  • Don’t use Checkbox for a setting that applies immediately — use <Switch>.
  • Don’t use Checkbox for 1-of-N — that’s <RadioGroup>.

RadioGroup

@na/ui/components/radio-group

1-of-N choice where options fit on screen. Wraps Radix RadioGroup.

Anatomy

○ Light ← RadioGroupItem + Label per option
● Dark ← selected (filled center)
○ System

States

StateAppearance
defaultborder-border 16px circle
focusring-2 ring-ring ring-offset-2
selectedbg-primary filled inner circle
disabledopacity-50

Code

<RadioGroup
value={theme}
onValueChange={setTheme}
>
<div className="flex items-center gap-2">
<RadioGroupItem
id="theme-light"
value="light"
/>
<Label htmlFor="theme-light">Light</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
id="theme-dark"
value="dark"
/>
<Label htmlFor="theme-dark">Dark</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
id="theme-system"
value="system"
/>
<Label htmlFor="theme-system">System</Label>
</div>
</RadioGroup>

A11y

  • Radix handles role="radiogroup" + role="radio" + arrow-key navigation between options.
  • Group label via a sibling <Label> or wrap in <fieldset> + <legend>.

Do

  • Use for 2-4 options where visibility matters (Plan: Free / Pro / Enterprise).
  • Stack vertically — horizontal RadioGroups are cramped and hard to scan.

Don’t

  • Don’t use for > 5 options — switch to <Select>.
  • Don’t use for boolean — that’s a <Switch> or <Checkbox>.

Switch

@na/ui/components/switch

Instant-apply boolean. Saves on toggle (or marks the form dirty). Wraps Radix Switch.

Anatomy

[⚪] off ← thumb on left, track muted
[ 🟢] on ← thumb on right, track primary

States

StateAppearance
offbg-input track, thumb left
onbg-primary track, thumb right
focusring-2 ring-ring ring-offset-2
disabledopacity-50 cursor-not-allowed

Animation: 100 ms thumb slide.

Code

// Settings row pattern (S1 from 13-section-anatomy)
<div className="flex items-center justify-between">
<Label htmlFor="kb-toggle">Read knowledge base</Label>
<Switch
id="kb-toggle"
checked={readKb}
onCheckedChange={setReadKb}
/>
</div>

A11y

  • Radix renders role="switch" + aria-checked automatically.
  • The Label MUST describe what’s enabled/disabled, not the state (“Read knowledge base”, not “Knowledge base on”).

Do

  • Use for settings that apply immediately (enable/disable a feature).
  • Pair with a <Label> describing what it controls.
  • Pair with an inline <Alert variant="warning"> if turning the switch on requires an unmet prerequisite (e.g., toggle KB on but KB is empty).

Don’t

  • Don’t use a Switch for a required form field — use <Checkbox>.
  • Don’t add a “Save” step after a Switch — Switches should apply instantly. (If they can’t, reconsider the UX.)
  • Don’t use a Switch for two-state selection where neither state is “off” (e.g., AM/PM) — that’s a <RadioGroup> or segmented control.

Slider

@na/ui/components/slider

Continuous numeric input. Wraps Radix Slider.

Anatomy

Label 1.20× ← Label + value display (right-aligned tabular-nums)
─────●─────────────── ← track + draggable thumb

States

StateAppearance
trackbg-secondary (full) + bg-primary (filled portion to thumb)
thumbbg-background border-2 border-primary 16px circle
focusring-2 ring-ring ring-offset-2 on the thumb
disabledopacity-50

Code

// Settings row pattern (S7 from 13-section-anatomy)
<div className="space-y-2">
<div className="flex justify-between text-sm">
<Label>Speaking rate</Label>
<span className="font-medium tabular-nums">{rate.toFixed(2)}×</span>
</div>
<Slider
min={0.25}
max={4}
step={0.05}
value={[rate]}
onValueChange={([v]) => setRate(v)}
aria-label="Speaking rate"
aria-valuetext={`${rate.toFixed(2)}× speed`}
/>
</div>

A11y

  • Radix handles role="slider", aria-valuemin, aria-valuemax, aria-valuenow.
  • ALWAYS set aria-valuetext with a human phrase (“1.20× speed”, “0 dB”, “70%”). The default aria-valuenow reads as a bare number which is meaningless out of context.
  • Keyboard: ↑↓ ← → adjust by step, Home/End jump to min/max, PgUp/PgDown step by 10× or by the configured large-step.

Do

  • Use for temperature, percentages, ratings — values with a natural range.
  • Always show the current value next to the slider, right-aligned, tabular-nums.
  • Set sensible step — too fine and users overshoot; too coarse and they can’t fine-tune.

Don’t

  • Don’t use Slider for discrete integer picks (1-10) — use <Input type="number">.
  • Don’t use Slider as a tab switcher.
  • Don’t show only the slider without a numeric readout — users need to know the exact value.

Settings row primitives — ToggleRow / SelectRow / SliderRow / NumberInputRow / ActionRow

These five components codify the most-used row patterns from 13-section-anatomy.md § Settings rows. Use them in Settings pages. They replace 5–10 lines of hand-rolled <div className="flex items-center justify-between"> + Label + control per row with one declarative line.

A page that uses these primitives reads as the design intent — every row is a named pattern, not a bag of layout classes.

<ToggleRow> (S1)

@na/ui/components/ToggleRow

<ToggleRow
id="kb-toggle"
label="Read knowledge base"
description="When on, the agent retrieves from the KB before answering." // optional
checked={readKb}
onCheckedChange={setReadKb}
alert={!hasDocs ? 'Knowledge base is empty — upload docs first.' : undefined} // optional inline Alert
/>

Anatomy. Label (+ optional description) on the left, <Switch> on the right. Optional inline <Alert> rendered below the row when a precondition is unmet.

Props. id · label · description? · checked · onCheckedChange · disabled? · alert? · alertVariant? ('default' | 'destructive') · className?

<SelectRow> (S5)

@na/ui/components/SelectRow

<SelectRow
label="Language"
value={lang}
onValueChange={setLang}
options={[
{ value: 'vi-VN', label: 'Vietnamese (vi-VN)' },
{ value: 'en-US', label: 'English (en-US)' },
]}
placeholder="Choose a language"
/>
// Grouped — for "by provider" or "by category" lists
<SelectRow
label="Voice"
value={voice}
onValueChange={setVoice}
groups={[
{ label: 'Google', options: [{ value: 'vi-A', label: 'Standard A' }] },
{ label: 'OpenAI', options: [{ value: 'alloy', label: 'Alloy' }] },
]}
/>
// With per-option meta (provider, gender, etc.)
<SelectRow
label="LLM model"
value={llm}
onValueChange={setLlm}
options={LLM_MODELS.map((m) => ({ value: m.id, label: m.label, meta: m.provider }))}
/>

Anatomy. Stacked: Label above (Attio quiet variant — text-muted-foreground text-xs by default) + <Select> below + optional muted help text.

Props. id? · label · value · onValueChange · options? (flat) · groups? (grouped) · placeholder? · description? · disabled? · quietLabel? (default true) · className?. Pass either options OR groups — not both.

<SliderRow> (S7)

@na/ui/components/SliderRow

<SliderRow
label="Speaking rate"
value={rate}
onValueChange={setRate}
min={0.25}
max={4}
step={0.05}
formatValue={(v) => `${v.toFixed(2)}×`}
ariaUnit="speed"
/>

Anatomy. Label + value display side-by-side on top (right-aligned, tabular-nums), full-width slider below.

Required a11y. aria-valuetext is set automatically from formatValue + ariaUnit so screen readers hear “1.20× speed” instead of “1.2”.

Props. label · value · onValueChange · min · max · step? · disabled? · formatValue? · ariaUnit? · description? · className?

<NumberInputRow> (S8)

@na/ui/components/NumberInputRow

<NumberInputRow
id="top-k"
label="Top-K results"
value={topK}
onChange={setTopK}
description="How many docs to retrieve per query." // optional
suffix="tokens" // optional unit suffix
/>

Anatomy. Stacked: Label above + numeric <Input> (with optional unit suffix to the right) + optional muted help text.

Props. id · label · value · onChange · min? · max? · step? · placeholder? · description? · suffix? · disabled? · className?

<ActionRow> (S10)

@na/ui/components/ActionRow

<ActionRow
label="Test voice"
description="Plays a sample using the current voice settings." // optional
action={
<Button
variant="outline"
size="sm"
onClick={handleTest}
disabled={pending}
>
<Volume2 className="size-4" /> Test voice
</Button>
}
status={
<MutationStatus
mutation={previewState}
successText=""
/>
} // optional
/>

Anatomy. Label (+ optional description) on the left, action button(s) on the right. Optional <MutationStatus> rendered below, right-aligned.

Props. label · description? · action · status? · className?

When to reach for these vs. bare primitives

Use a row primitive when…Use bare primitives when…
Building a Settings page (Template D)Inside a <Dialog>, <HoverCardPreview>, or toolbar
Inside a <FormSection> bodyBuilding a non-row layout (matrix, custom split)
The row matches an S* pattern from 13-section-anatomyThe row is genuinely novel and should become a new primitive

If you find yourself reaching for <div className="flex items-center justify-between"> + <Label> + <Switch> in a Settings tab, you wanted <ToggleRow>.

Adding a new row primitive

If your screen has a row pattern that’s not S1/S5/S7/S8/S10 AND it appears in 3+ places, codify it. Read 01-principles.md § C8 Restraint before writing the file: prefer extending one of the existing five with a new prop over adding a sixth primitive.


Form

@na/ui/components/form

react-hook-form wrapper. Provides <FormField>, <FormItem>, <FormLabel>, <FormControl>, <FormMessage>.

Do

  • Use FormProvider around every form in the admin app.
  • Use <FormField> per field — it wires up label, input, error message automatically.
  • Surface server errors via form.setError('root', { message }) and render <FormError />.

Don’t

  • Don’t render raw <input> inside a Form — use <FormField> with <FormControl>.
  • Don’t manage form state in component useState when react-hook-form exists.

Data Display

Table / DataTable / SortableTableHead

@na/ui/components/table · @na/ui/components/DataTable · @na/ui/components/SortableTableHead

Three layers:

PrimitiveRoleWhen to reach for it
<Table> + <TableHeader>/<TableRow>/<TableHead>/<TableCell>Bare HTML-table primitives with the design-system styling baked in (border, hover, header weight).Tiny static tables nested inside another component (e.g. a 3-row table inside a <Card> widget).
<DataTable>Rendering shell for list-style tables. Sort, selection, empty states, row actions, loading skeleton — all opt-in.Any list page (/agents, /tools, /review, /audit).
<SortableTableHead>Used by <DataTable> automatically when a column is sortable: true. Exposed for hand-rolled tables that want the same affordance.Custom tables (matrix, grouped, hierarchical) where <DataTable> doesn’t fit but you still want sort.

Full layout rules in 03-layout.md → Tables. Visual highlights, all enforced by the primitives:

  • Edge-to-edge flat by default. No <Card> wrapper, no <div className="rounded-lg border">. Live-inspected Attio rule per references/attio-design-language.md L25–31.
  • Column header. 12px, text-muted-foreground, font-medium. Set by <TableHead> itself — don’t override.
  • Numeric columns: pass numeric: true on the column descriptor; <DataTable> adds text-right tabular-nums to both header and cell.
  • Interactive rows get hover:bg-muted/50 transition-colors automatically (baked into <TableRow>).
  • Whole-row click via onRowClick. <DataTable> handles event.stopPropagation() for the rowActions cell.
  • Entity-name cells in cell renderers should be plain text links with hover underline, optionally wrapped in <HoverCardPreview>. Never <Button variant="link">.
  • Footer row with per-column aggregates is not yet implemented — open issue, expected as a future <DataTable footer> prop.
  • Bulk-action toolbar lives above the table in <ContentToolbar> per 05-patterns.md § Bulk action toolbar. <DataTable> exposes selectedIds + onSelectionChange for the wiring.
  • Filter chips live in <FilterChips> above the table, not as a row of <Button variant="outline"> with count badges.
  • Never raw <table>, <thead>, <tbody>.

<DataTable> — anatomy

<DataTable
data={agents}
columns={[
{ id: 'name', header: 'Name', sortable: true, cell: (a) => <Link>{a.name}</Link> },
{ id: 'status', header: 'Status', cell: (a) => <StatusBadge>{a.status}</StatusBadge> },
{
id: 'updated',
header: 'Updated',
sortable: true,
numeric: true,
cell: (a) => formatDate(a.updatedAt),
},
]}
rowId={(a) => a.id}
// Sort (controlled — caller manages state, DataTable just renders)
sortColumn={sort.column}
sortDirection={sort.direction}
onSortChange={(col, dir) => setSort({ column: col, direction: dir })}
// Selection (controlled)
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
// Behavior
onRowClick={(a) => navigate(`/agents/${a.id}`)}
rowActions={(a) => <RowActionsDropdown agent={a} />}
// States
isLoading={isLoading}
isFiltered={searchQuery.length > 0}
emptyState={
<EmptyState
variant="blank-slate"
title="No agents yet"
action={<Button>+ New agent</Button>}
/>
}
filteredEmptyState={
<EmptyState
variant="filtered"
title="No agents match"
action={<Button onClick={clearFilters}>Clear filters</Button>}
/>
}
/>

Column shape (DataTableColumn<T>):

PropTypeNotes
idstringStable column id. Also the sort identifier when sortable: true.
headerReactNodeHeader label.
cell(row: T) => ReactNodeRequired. Explicit renderer — no accessorKey shortcut (clearer for non-trivial cells).
sortablebooleanRenders the header through <SortableTableHead> with caret + click handler.
numericbooleanRight-align + tabular-nums on header and cell.
classNamestringApplied to header AND cells.
cellClassNamestringCell-only override.

The DataTable does NOT do its own:

  • Search input (caller provides via <ContentToolbar> — keep search state in the page).
  • Pagination (use <TablePagination> below the DataTable).
  • Filtering (caller passes already-filtered data; sets isFiltered={true} so the right empty state is picked).

This is intentional. The previous DataTable bundled search + pagination, which made server-side pagination awkward. Now: page owns state, DataTable renders.

Empty-state rendering — two modes.

When data.length === 0, DataTable picks one of two render modes via the emptyMode prop. The full decision tree lives in 05-patterns.md → Empty State § Tables: drop chrome vs keep chrome — the summary:

emptyModeWhat rendersUse when
'standalone' (default)Empty state alone — no <Table> / <TableHeader> / <TableBody> wrappers.Blank-slate primary list (/agents, /tools), filtered-to-zero on a primary list, permission-blocked, error. Column headers above an empty body are scaffolding for data that isn’t there.
'in-table'Headers stay; empty content lives inside a single <TableRow> with colSpan filling the body.Streaming / live tables (rows about to arrive — workflow runs, log streams, polling views) and sub-tables in multi-table detail pages where dropping chrome would collapse the visual rhythm.
emptyMode="standalone" (default) emptyMode="in-table"
┌─ Page ──────────────────────────────┐ ┌─ Page ──────────────────────────────┐
│ <ListPageBar> │ │ <DetailPageBar> │
│ <ContentToolbar> [filter] [search] │ │ │
│ │ │ ┌──────────────────────────────────┐ │
│ ┌─────────────────────────┐ │ │ │ Name | Status | Latency | … │ │ ← headers stay
│ │ <Empty state centered> │ │ │ ├──────────────────────────────────┤ │
│ │ Icon · Title · CTA │ │ │ │ <Empty state centered> │ │
│ └─────────────────────────┘ │ │ └──────────────────────────────────┘ │
└──────────────────────────────────────┘ └──────────────────────────────────────┘

Loading state always renders headers + skeleton rows regardless of emptyMode — loading is “data is coming, here’s the structure.”

Don’t reach for 'in-table' to “keep things looking like a table” — the default is right for ~95% of cases. The carve-out exists for genuine streaming surfaces and for sub-tables that anchor a multi-section detail page. If neither applies, leave it 'standalone'.

<SortableTableHead> — anatomy

<SortableTableHead
sortDirection={sortColumn === 'name' ? sortDirection : null}
onSort={() => /* cycle asc → desc → null */}
>
Name
</SortableTableHead>

Renders a muted ArrowUpDown icon to indicate sortability. When the column is the active sort, the icon flips to ArrowUp / ArrowDown and aria-sort becomes ascending / descending. The export cycleSort(column, prev) is the standard 3-state cycler — use it from your onSortChange handler.

A11y. Wraps content in a <button> so the click target is keyboard-reachable. aria-sort on the <th> is set automatically. Non-active sortable columns show the icon at opacity-40 so the affordance reads even at rest.

Card

@na/ui/components/card

Surface for self-contained content containers — clickable entities, KPI tiles, empty-state CTAs. Border only at rest, no shadow.

<Card>
<CardContent>{/* default p-4 — do not override */}</CardContent>
</Card>
  • CardContent default p-4don’t override.
  • Selected state: ring-2 ring-primary (not border-primary).
  • No hover state unless the whole card is clickable.

Use a <Card> only for:

  • Dashboard metric / KPI tile (label + big number + trend).
  • Clickable entity preview in a <CardGrid> (agents, tools, channels).
  • Empty-state CTA (“create your first X” centered block).
  • Self-contained widget (iframe preview, embedded player).
  • Danger zone (red-tinted card, always last in a settings page).

Do NOT use a <Card> for:

  • ❌ Tables — edge-to-edge flat.
  • ❌ Form field groups — <FormSection> + <Separator>.
  • ❌ Right-side property panels — flat <PropertyList> in <RightPanel>.
  • ❌ Settings / configuration sections — flat, separated by <Separator>.
  • ❌ Progress bars / alerts / status banners — flat alerts with role="status" or role="alert".
  • ❌ Nested inside another <Card> (flatten — one of them isn’t really a content container).
  • ❌ Around a collapsible section — <CollapsibleSection> owns the boundary via chevron.

Live-inspected Attio rule; see references/attio-design-language.md L152–162 and references/attio-layout-decision-guide.md § “Card vs flat” for worked examples.

Avatar

@na/ui/components/avatar

User or entity image. Falls back to initials on image error.

Do

  • Use <AvatarImage> + <AvatarFallback> — always provide fallback.
  • Size: 24px (rail/chat thread list), 32px (sidebar footer), 40px (detail header).

Don’t

  • Don’t show a broken-image icon — fallback initials are the contract.
  • Don’t use Avatar for decoration — it represents an actual identity.

EmptyState

@na/ui/components/EmptyState

First-class empty-state pattern. Variant-driven — different visual densities for different “empty” reasons. See 05-patterns.md → Empty State § Variants for the why.

Variants (variant?: 'blank-slate' | 'filtered' | 'permission' | 'error' — default 'blank-slate'):

VariantWhenShape
blank-slateUser has never had data hereFull size (py-16): icon + title + description + primary CTA (+ optional secondary)
filteredData exists; current filters hide itCompact (py-8): no icon, no description; just title + a single “Clear filters” action
permissionUser lacks access to this surfaceFull size: lock icon + “ask admin” description + “Request access” CTA
errorNetwork / fetch failedFull size: alert icon + “check connection” description + “Retry” CTA

Why variants matter. A permission-blocked empty state with a “Create your first X” CTA is cruel — the user can’t create anything. A filtered-zero with a giant icon and “Get started!” copy is nonsense — they DID get started, the filter is just hiding it. The variant prop forces the right visual weight + copy shape for each context.

// Blank slate (truly empty)
<EmptyState
variant="blank-slate"
icon={<Inbox className="size-10" />}
title="No agents yet"
description="Create your first voice agent to start handling calls."
action={<Button>Create your first agent</Button>}
secondaryAction={<Button variant="outline">Import from CSV</Button>}
/>
// Filtered to zero (compact, no icon)
<EmptyState
variant="filtered"
title="No agents match your search"
action={<Button variant="outline" onClick={clearFilters}>Clear filters</Button>}
/>
// Permission denied
<EmptyState
variant="permission"
icon={<Lock className="size-10" />}
title="You don't have access to this page"
description="Members management is admin-only. Ask a workspace admin to invite you."
action={<Button>Request access</Button>}
/>
// Network error
<EmptyState
variant="error"
icon={<AlertCircle className="text-destructive size-10" />}
title="Couldn't load agents"
description="Check your connection and try again."
action={<Button onClick={refetch}>Retry</Button>}
/>

Pairs with <DataTable> — pass emptyState (blank-slate) and filteredEmptyState (filtered) as separate props; DataTable picks the right one based on its isFiltered prop. By default DataTable drops table chrome around the empty state (emptyMode="standalone"); pass emptyMode="in-table" for streaming or sub-table contexts where headers should stay. See the DataTable empty-state rendering callout and 05-patterns.md § Tables: drop chrome vs keep chrome.

Icon prop accepts ReactNode (preferred — Lucide icons at size-10 per 02-foundations.md § Icons § Sizing) or a string (legacy — emoji rendered at text-5xl).

Don’t conflate variants. If you find yourself reaching for <EmptyState variant="blank-slate"> inside a filtered-zero condition, stop — 'filtered' is the right variant.

Skeleton

@na/ui/components/skeleton

Loading placeholder. Shape matches the content it replaces.

Do

  • Use <Skeleton> on initial load — never a full-page spinner.
  • Size the skeleton to the real content (h-8 w-64 for a title, h-64 w-full for a table).

Don’t

  • Don’t animate the skeleton yourself — the component already does.
  • Don’t keep skeletons visible after data loads — swap to real content.

Loader

@na/ui/components/Loader

Small inline spinner. The “I’m doing something brief” affordance inside another component. Never page-level.

Anatomy

⏳ Just a spinning lucide Loader2 icon at h-4 w-4.

States. Single state — spinning. Color via the parent’s text-* class.

Sizing

ContextClass
Inside a buttonh-4 w-4
Inside an inputh-4 w-4
Inside a small popoverh-3.5 w-3.5
Inside a list rowh-3.5 w-3.5

Code

<Button disabled>
<Loader className="text-muted-foreground" />
<span className="ml-2">Loading…</span>
</Button>
// Inline within a row
<div className="flex items-center gap-2">
<Loader className="size-3.5 text-muted-foreground" />
<span className="text-muted-foreground text-xs">Connecting…</span>
</div>

A11y

  • Wrap in role="status" + aria-live="polite" if the spinner indicates a meaningful change of state.
  • For decorative spinners (e.g., already inside a button labeled “Saving…”), aria-hidden="true" is correct.

Do

  • Use inside another component (button, input, row, popover).
  • Pair with text when the action is non-obvious (“Connecting…”, “Loading agents…”).

Don’t

  • Don’t use as a page-level loading state — that’s <Skeleton>.
  • Don’t use as the only feedback for a click-triggered mutation — use <ActionButton> instead, which integrates the spinner with success/error states.
  • Don’t size > 16px in normal contexts. Empty states use a 40px icon, not Loader.

Separator

@na/ui/components/separator

Horizontal (or vertical) divider line. bg-border at 1px / 100% width.

Anatomy

content above
───────────────────── ← <Separator /> — h-px bg-border
content below

Variants

OrientationClassWhen
horizontal (default)h-px w-fullBetween distinct content blocks within one column
verticalw-px h-fullBetween siblings in a flex row (toolbar dividers)

Code

<Separator />
<Separator orientation="vertical" className="mx-2 h-4" />

When to use Separator vs whitespace

Per 03-layout.md § Template D and 13-section-anatomy.md § FormSection inner anatomy:

  • Use Separator at major content-zone boundaries (narrow stack → wide section, KPI strip → charts row, table footer → pagination footer).
  • Prefer whitespace-only between sibling <FormSection> blocks in a Settings page (Attio convention) — space-y-10 is enough.
  • Don’t use Separator as a fallback for missing structure — if two sections need a divider to make sense, they probably need different headings or a <CollapsibleSection> boundary instead.

A11y

  • Radix renders role="separator" + aria-orientation automatically.
  • Decorative-only Separators (visual rhythm, not content boundary) should set decorative (Radix prop) which removes them from the a11y tree.

Do

  • Use sparingly. Whitespace separates sections; Separator separates content zones.
  • Use vertical Separators in toolbars to group related actions (“View” segment dividers between Filter / Sort / Search).

Don’t

  • Don’t use Separator between every form row — that’s noise.
  • Don’t customize the color of a Separator — it’s bg-border for consistency across the app.
  • Don’t stack two Separators with whitespace between — pick one boundary or the other.

Skeleton vs Loader vs Spinner

Three different loading surfaces; they are not interchangeable.

SurfaceWhenExample
<Skeleton>Initial data load when shape is known (list, table, card grid). Default choice for a route that fetches on mount.Table loads for 800ms on navigate-in.
<ActionButton> pendingClick-triggered mutation with inline feedback.”Save” button that takes 200–800ms.
<MutationStatus>Mutation feedback outside a button (e.g. top of a card that’s not button-triggered).Bot toggle on a tool card.
<Loader />Small inline spinner inside another component. Never page-level.Loading state inside a <CommandPalette> result list.
Full-page spinnerNever. This is a blocker-severity violation.

If in doubt, reach for <Skeleton> — it’s the right answer 9 times out of 10.

Progress

@na/ui/components/progress

Determinate progress bar (0-100).

Do

  • Use for uploads, imports, long-running jobs.
  • Pair with a text label (“Uploading 12 of 47 files”).

Don’t

  • Don’t use as a decorative element.
  • Don’t use Progress for indeterminate loading — use <Skeleton> or <Loader2 className="animate-spin">.

Tooltip

@na/ui/components/tooltip

Hover-triggered label or context.

Do

  • Use for icon-only buttons (the invisible label).
  • Use to explain why a button is disabled (disabledReason on Button handles this).

Don’t

  • Don’t put long paragraphs in a tooltip — use inline helper text.
  • Don’t put critical info only in a tooltip — tooltips are inaccessible on touch devices without hover.
  • Don’t restate visible label text.

Popover

@na/ui/components/popover

Anchored floating panel. Used for filters, column selectors, date pickers.

Do

  • Use <PopoverTrigger asChild> to wire up any trigger element.
  • Close on outside click (Radix default).

Don’t

  • Don’t use Popover for confirmations — use <ConfirmPopover>.
  • Don’t nest Popovers inside Popovers.

Accordion / Collapsible

@na/ui/components/accordion / collapsible

Expandable sections.

Do

  • Use <Accordion type="single"> when only one item opens at a time.
  • Use <Collapsible> for a single expandable region.

Don’t

  • Don’t use Accordion as primary navigation — that’s the sidebar.
  • Don’t put forms inside Accordion — users forget state when sections collapse.

@na/ui/components/carousel

Horizontal paginated content. Used rarely (marketing-style surfaces inside the app).

Do

  • Use for 3-8 items that don’t fit in a row.

Don’t

  • Don’t use Carousel for important content — users miss non-first items.
  • Don’t auto-rotate.

ScrollArea / resizable

@radix-ui/react-scroll-area / @na/ui/components/resizable

ScrollArea is for custom-styled scrollbars inside a contained region. resizable is for split panels with drag-to-resize divider.

Do

  • Use ScrollArea inside <Sheet> content, inside a constrained Card with scrollable list.

Don’t

  • Don’t use ScrollArea at the page level — <PageContent> is the page scroll zone.
  • Don’t nest multiple ScrollAreas.

Attio-native primitives (Plan 2)

Net-new primitives introduced by the Attio layout migration. Specs are normative — do not roll your own approximation while the primitive is being built. If a screen needs one before it ships, flag it in review and track via the migration plan at docs/plans/2026-04-24-attio-layout-migration.md.

RightPanel

@na/ui/components/RightPanel (Plan 2)

Flat right-side detail panel for the 3a Split detail archetype. Has its own tab bar (e.g. Details / Comments). Second allowed scroll zone beyond <PageContent> — both scroll independently.

<RightPanel
width="narrow" // "narrow" = 320px, "wide" = 400px
tabs={[{ label: 'Details' }, { label: 'Comments' }]}
>
<CollapsibleSection
title="Record Details"
defaultOpen
>
<PropertyList>{/* property rows */}</PropertyList>
</CollapsibleSection>
<CollapsibleSection title="Related">{/* … */}</CollapsibleSection>
</RightPanel>

Do

  • Use only on genuine 3a Split screens — main area has real activity/timeline, right panel has static properties (e.g., /review/:id).
  • Nest <CollapsibleSection> > <PropertyList> > <PropertyRow> > <InlineEditField>.

Don’t

  • ❌ Wrap contents in a <Card> — right panel is flat.
  • ❌ Use on a settings sub-tab (use Form archetype with <PageContent narrow> instead).
  • ❌ Put “Save” buttons inside — inline-edit commits on blur.

PropertyList / PropertyRow

@na/ui/components/PropertyList (Plan 2)

Flat key-value list. Lives inside <RightPanel>, <CollapsibleSection>, or at the top of a settings page. Each row is <PropertyRow>: icon + label (fixed-width left column) + inline-editable value.

<PropertyList>
<PropertyRow
icon={Globe}
label="Domain"
>
<InlineEditField
value={domain}
onCommit={save}
type="url"
/>
</PropertyRow>
<PropertyRow
icon={User}
label="Owner"
>
<InlineEditField
value={owner}
onCommit={save}
type="person"
/>
</PropertyRow>
<PropertyRow
icon={Calendar}
label="Created"
>
<span className="text-muted-foreground">{formatDate(created)}</span>
</PropertyRow>
<ShowAllValuesToggle />
</PropertyList>

Do

  • Icon 14–16px muted; label 12px muted font-medium fixed left column (~120–140px).
  • Read-only values render as plain text (not an <InlineEditField>).
  • <ShowAllValuesToggle> at the bottom when low-priority attributes are hidden.

Don’t

  • ❌ Wrap each row in a <Card>.
  • ❌ Add a “Save” button — editing commits on blur.
  • ❌ Use for form field groups (use <FormSection> + <Label> + <Input> instead).

InlineEditField

@na/ui/components/InlineEditField (Plan 2)

Click-to-edit value in a <PropertyRow>. Default state is read-only text; click turns the cell into an <Input>, <Select>, <DatePicker>, or <Combobox> depending on the type prop.

<InlineEditField
value={name}
onCommit={async (next) => await save(next)}
type="text" // "text" | "url" | "number" | "date" | "select" | "person"
placeholder="Set a value…" // shown when value is empty
/>
  • Blur commits silently; shows a brief inline <MutationStatus> badge on success.
  • Enter commits. Esc cancels. Tab moves to next row’s value. ↑/↓ move between rows.
  • Optimistic update with rollback on failure (inline error next to the row).
  • Empty state: italic muted “Set a value…” placeholder.

Don’t

  • ❌ Add a confirm button.
  • ❌ Wrap in a <Form> — inline-edit bypasses the react-hook-form pattern.

CollapsibleSection

@na/ui/components/CollapsibleSection (Plan 2)

Extension of <ContentSection> with a ▾ chevron on the section title. State persists per-user in localStorage keyed by (routeId, sectionId). Not URL-backed — collapse is personal.

<CollapsibleSection
sectionId="record-details"
title="Record Details"
defaultOpen
>
<PropertyList>{/* … */}</PropertyList>
</CollapsibleSection>

Do

  • Use inside <RightPanel> for the Record Details + Lists + Related sections.
  • Use inside <PageContent spaced> for long scroll-through settings pages.

Don’t

  • ❌ Wrap in a <Card> — the chevron is the boundary.
  • ❌ Sync collapse state to URL — it’s personal, not shared.

ViewSwitcher

@na/ui/components/ViewSwitcher (Plan 2)

Segmented control above a collection, flipping between Table / Kanban / Calendar views on the same underlying query. URL-backed via ?view=<id>.

<ViewSwitcher
views={[
{ id: 'table', label: 'Table', icon: TableIcon, render: <ReviewTable /> },
{ id: 'kanban', label: 'Kanban', icon: Columns, render: <ReviewBoard /> },
]}
defaultView="table"
/>

Do

  • Use only on task/flow screens where switching earns its keep (/review, /agents/:id/tests history).
  • Default to the “scan” view (usually Table), not the “triage” view.
  • All views share the same query / filter / sort state.

Don’t

  • ❌ Use on registries, settings, or audit logs (/agents, /tools, /versions, /security) — see references/attio-layout-decision-guide.md § “Decision tree 1” and § view-switcher rules.
  • ❌ Fork the data fetch per view — one query, many renderers.

FilterChips

@na/ui/components/FilterChips (Plan 2)

Pill-style chip row above a collection. Replaces button-rows-as-filters and dropdown-first filter menus. AND semantics; clicking a chip re-opens its picker.

<FilterChips
chips={[{ id: 'status', label: 'Status', value: 'Open', onEdit: openPicker, onRemove: clear }]}
onAddFilter={openAttributePicker} // "+ Filter" button
/>

Do

  • Live in the left slot of <ContentToolbar>.
  • Combine multiple chips with AND. For OR semantics, save a separate named view.

Don’t

  • ❌ Use <Button variant="outline"> rows with count badges as filters (the pattern in KnowledgeBaseTab.tsx:420–454).
  • ❌ Render filters as a <Select> dropdown only — the chip pattern exposes active state.

HoverCardPreview

@na/ui/components/HoverCardPreview (Plan 2)

Anchored popover on entity-name hover in a table row. 200ms open, 100ms close. Contains avatar + key fields + quick actions.

<HoverCardPreview
trigger={<a href={`/agents/${agent.id}`}>{agent.name}</a>}
content={
<>
<Avatar />
<PropertyList compact>{/* 3–5 rows */}</PropertyList>
<HoverCardActions>
<Button variant="ghost" size="sm">Copy link</Button>
<Button variant="ghost" size="sm">Open in new tab</Button>
</HoverCardActions>
</>
}
/>

Do

  • Anchor to the trigger element, not the cursor.
  • Show 3–5 key properties max — this is a peek, not the full record.
  • Quick actions only (Copy, Open, Add to list). No destructive actions.

Don’t

  • ❌ Fire on touch devices (hover is desktop-only; tap navigates).
  • ❌ Put Delete / Archive / Reset inside — destructive actions belong on the record page.

InboxList

@na/ui/components/InboxList (Plan 2)

Inbox archetype: list on the left (360–420px), preview on the right. Keyboard triage with j/k/e. Used for /review, notifications, any queue the user works through.

<InboxList
items={reviews}
selectedId={selectedId}
onSelect={setSelectedId}
onArchive={archive}
renderRow={(item) => <InboxRow item={item} />}
renderPreview={(item) => <ReviewDetail item={item} />}
/>

Keyboard contract (standardized across nx-agent):

  • j / — next item
  • k / — previous item
  • e — archive current
  • Enter — open in canonical detail page (leave inbox)
  • ⌘Enter — mark read and move to next
  • Esc — focus back to list / close

Don’t

  • ❌ Wrap preview in a <Card> — flat.
  • ❌ Re-order the list out from under the user — new items appear at the top with a muted “new” divider.

BoardView

@na/ui/components/BoardView (Plan 2)

Kanban layout: columns = status attribute values. Drag-drop + multi-select with shift-click or per-card checkbox.

<BoardView
items={items}
groupBy="status"
renderCard={(item) => <BoardCard item={item} />}
onMove={(id, toStatus) => update(id, toStatus)}
/>

Do

  • Always groupable by a single status-type attribute.
  • Column header: stage name + count. Click for Hide/Delete menu.
  • Card: avatar + title row + 2–3 attribute rows. No shadow at rest.

Don’t

  • ❌ Use as a table-replacement on settings / registry screens — kanban requires a genuine status pipeline.
  • ❌ Add shadow to resting cards.

FocusLayout / Stepper

@na/ui/components/FocusLayout

Main-content takeover for the Focus archetype. Sidebar stays visible — the focus surface is scoped to the main pane, not the viewport. Two-row header (close+breadcrumb on row 1, stepper on row 2). Sticky bottom action bar via the actions prop (full-width container, right-aligned cluster). See 03-layout.md → Template F for the complete template.

<FocusLayout
closeTo="/agents"
breadcrumb={['Agents', 'Create']}
stepper={{
current: 2,
steps: ['Template', 'Configure', 'Connect tools', 'Review'],
}}
actions={
<>
<Button
variant="ghost"
onClick={cancel}
>
Cancel
</Button>
<Button
variant="outline"
onClick={prev}
>
Previous
</Button>
<Button onClick={next}>Next</Button>
</>
}
>
<div className="mx-auto max-w-2xl space-y-6 py-12">
<FormSection title="Choose a template">{/* step content */}</FormSection>
</div>
</FocusLayout>

Mount inside AppLayout so the Sidebar renders as a sibling. Routes for focus pages go in the same children block as regular pages, NOT outside the app shell.

Do

  • Use for 3+ step linear wizards (import, onboarding, complex create).
  • top-left closes to closeTo, not browser back. Esc same.
  • Two-row header — stepper gets its own row.
  • Body uses bg-background (same as the rest of the app); centered max-width content. Don’t tint the body — focus is established by the two-row header + scoped main pane + sticky action bar, not by surface color.
  • Pass step controls via the actions prop — they render in a sticky bottom bar (border-t bg-background) with the same shape as the Form archetype’s <ActionBar>.

Don’t

  • ❌ Mount the route outside AppLayout — that overlays the Sidebar, which violates the “main-pane, not viewport” rule.
  • ❌ Use a floating-pill action cluster (fixed bottom-6 right-6, bordered chip) — the action bar is a full-width sticky bar, not a chip.
  • ❌ Cram the stepper onto the same row as the breadcrumb — header is two rows.
  • ❌ Put the wizard inside a <Dialog> — that’s the “Dialog that outgrew itself” smell.
  • ❌ Use for single-step forms — that’s a Form page or Dialog.
  • ❌ Allow random-access navigation between steps — future steps muted until reached.

Confirm-on-discard

<FocusLayout> is a focused page, not a modal. It does not render role="dialog" and does not trap focus — the user can click into the Sidebar to navigate away at any time. That makes “what happens when the user tries to leave a dirty draft?” a real question. Every exit path the user can reach must protect their work:

Exit pathWhen cleanWhen dirty
close buttonnavigate immediately<ConfirmPopover> via the closeAction prop on <FocusLayout> — anchors to the same ✕ slot
Cancel button (in the action bar)navigate immediatelywrap the Cancel <Button> in a <ConfirmPopover> with the same copy — anchors to Cancel
Esc keynavigate immediatelycaller-owned — programmatically click the ✕ ref to open the same popover, never silently bail
Sidebar click / browser backnavigate immediatelyuse React Router’s useBlocker (or equivalent) to intercept and show a <ConfirmDialog> — this IS a real modal escalation, but it’s user-initiated (they tried to leave)

Hard rule: if the user can reach an exit path while dirty, that path MUST trigger a confirmation. A silent no-op (the close handler returns early without navigating, with no UI) is forbidden — it looks broken to the user. Every “exit path” trigger in the table above is observable from the user’s perspective, so each gets a confirmation.

The popover variant is preferred wherever a trigger exists (✕, Cancel) — it anchors to the click and stays in context. The dialog escalation is only for navigation paths where there’s no in-flow trigger to anchor to (sidebar / browser back).

const closeAction =
isDirty && !submit.isPending ? (
<ConfirmPopover
message="Discard your draft? Any changes will be lost."
confirmLabel="Discard"
variant="destructive"
onConfirm={() => navigate('/agents')}
>
<FocusCloseButton /> {/* same look as the default ✕ */}
</ConfirmPopover>
) : undefined;
<FocusLayout
onClose={() => navigate('/agents')}
closeAction={closeAction}
...
>

Why a popover for ✕ specifically? It’s lighter UX — anchored next to the action that triggered it, no full-viewport scrim, doesn’t push the user out of context. Save the <ConfirmDialog> escalation for navigation that genuinely takes them out of the flow (sidebar click, browser back).


Feedback

This is where mutations + errors surface. 05-patterns.md → Feedback has the full pattern; here’s the component inventory.

ActionButton

@na/ui/components/ActionButton

Button that knows its mutation state. Ships with pending/success/error visuals.

<ActionButton
mutation={save}
onClick={() => save.mutate(data)}
idleIcon={<Save className="h-4 w-4" />}
pendingText="Saving…"
successText="Saved"
>
Save
</ActionButton>

Error placement: errorPlacement prop. Default "tooltip" (zero layout shift). Options: "tooltip", "below", "below-absolute", "none".

  • Tooltip (default) — AlertCircle in the button + message in tooltip. Best for toolbars.
  • Belowrole="alert" text below. Best for forms.
  • Below-absolute — floats below without affecting sibling height.
  • None — caller owns the error surface (FormError, toast).

Do

  • Use for every click-triggered mutation.
  • Provide pendingText and successText — the state transition is worth showing.
  • In form contexts, set errorPlacement="below" for instant visibility.

Don’t

  • Don’t use a plain <Button> + manual useState when you have a TanStack mutation — pass it to ActionButton.
  • Don’t toast the mutation’s result — ActionButton handles it inline.

MutationStatus

@na/ui/components/MutationStatus

Standalone mutation status line (spinner, success text, error text). Use when the status doesn’t belong inside a button — e.g., inside a card that’s not button-triggered.

FormError

@na/ui/components/FormError

Reads errors.root from react-hook-form and renders the form-level error above the action bar.

<form onSubmit={form.handleSubmit(onSubmit)}>
<FormSection>...</FormSection>
<FormError />
<ActionBar>...</ActionBar>
</form>

Paired with form.setError('root', { message: extractErrorMessage(err) }) in the submit handler.

Alert

@na/ui/components/alert

Full-width informational banner inside content.

Variants: default · destructive

Do

  • Use for page-level warnings (“Your subscription expires in 3 days”).
  • Use sparingly — banner overuse trains users to ignore them.

Don’t

  • Don’t use Alert as a success message after a mutation — that’s ActionButton.
  • Don’t stack more than one Alert on a page. If you have multiple, prioritize.

AlertDialog

@na/ui/components/alert-dialog

Modal variant of Dialog for irreversible actions. Generally prefer our semantic primitives (ConfirmDialog).

Dialog

@na/ui/components/dialog

Modal. Use only for:

  • Single-step yes/no confirmations (prefer <ConfirmDialog> / <ConfirmPopover>).
  • Short forms, ≤4 fields, no rich widgets (“Quick create”, “Rename”, “Move to…”).
  • Single-step commits that genuinely need focus-trap.

Max sizing: sm:max-w-md to sm:max-w-lg. No height hacks.

<Dialog
open={open}
onOpenChange={setOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Create agent</DialogTitle>
<DialogDescription>Name and describe your agent.</DialogDescription>
</DialogHeader>
{/* ≤4 fields */}
<DialogFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button onClick={handleSave}>Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>

Do

  • Always provide <DialogTitle> — accessibility requirement.
  • Dialog footer: Cancel left, primary action right.
  • Confirm button label matches the verb (“Create”, “Delete”) — never “OK”.

Don’t

  • ❌ Forms > 4 fields — use <Sheet>, a dedicated page, or Focus View for multi-step wizards.
  • ❌ Nest Dialogs or stack overlays (no drawer-in-dialog, no dialog-over-drawer).
  • ❌ Dismiss on Escape if there’s unsaved input without asking.

The “Dialog that outgrew itself” smell

If your Dialog has any two of these, it’s in the wrong primitive. Promote it.

SymptomLikely right primitive
Width escalation: sm:max-w-2xlmax-w-4xlmax-w-6xl<Sheet> (triage context) or dedicated page
Height hacks: h-[85vh], max-h-[90vh], nested overflow-auto<Sheet> or dedicated page
Custom header replacing <DialogHeader> (often with showCloseButton={false})Accessibility loss — promote
Tabs, split layout, or stepper inside the bodyFocus View (3d) for wizards, <Sheet> for tabs, page for splits
Back / Next buttons inside <DialogFooter>Focus View (3d)<FocusLayout> + <Stepper> + sticky bottom action bar via actions prop

Concrete example in nx-agent today: the KB preview Dialog at KnowledgeBaseTab.tsx:780–1124 hits four of these at once (max-w-6xl, h-[90vh], showCloseButton={false}, nested split inside body). P0 migration target → drawer or dedicated detail page.

See references/attio-layout-decision-guide.md § “Decision tree 2.5 — Dialog vs Drawer vs Page” for the full decision matrix.

ConfirmPopover

@na/ui/components/ConfirmPopover

Anchored popover confirm for routine, reversible destructive actions. Stays in context.

<ConfirmPopover
message="Remove this tool from the agent?"
confirmLabel="Remove"
variant="destructive"
onConfirm={() => removeTool.mutateAsync(toolId)}
>
<Button
variant="ghost"
size="icon-xs"
>
<Trash />
</Button>
</ConfirmPopover>

Do

  • Use for per-row deletes (remove tool, unlink channel, delete thread).
  • Use for undoable actions (revoke API key, disable feature).
  • Pick the right align for the trigger’s screen position:
    • Trigger on the right side of the screen / inside a row’s right gutter → keep the default align="end". The popover extends leftward, into the content, where there’s space.
    • Trigger on the left side / top-left corner (e.g. close in <FocusLayout>) → pass align="start". The popover’s left edge anchors to the trigger’s left edge and extends rightward where space exists.
    • Radix’s collision detection auto-flips on overflow either way, but the explicit choice avoids a flicker on first render and makes the intended direction obvious.

Don’t

  • Don’t use for catastrophic actions — use <ConfirmDialog>.
  • Don’t leave align on its default for left-anchored triggers — the popover will visually appear to extend off-screen before Radix flips it. Specify align="start".

ConfirmDialog

@na/ui/components/ConfirmDialog

Modal confirm for catastrophic, irreversible actions. Takes full attention.

<ConfirmDialog
trigger={
<Button
variant="destructive"
size="sm"
>
Delete Agent
</Button>
}
title="Delete this agent?"
description="This permanently deletes the agent, all threads, and all associated data. This cannot be undone."
confirmLabel="Delete"
variant="destructive"
onConfirm={() => deleteAgent(id)}
/>

Do

  • Use for delete-entire-entity actions.
  • Use for reset-all-data actions.
  • Make the confirm button label a verb that matches the action.

Don’t

  • Don’t use for routine deletes — that’s <ConfirmPopover>.
  • Don’t use generic “OK” labels — the button tells the user what will happen.

Sonner (toast)

@na/ui/components/sonner

Toast notifications. Rarely used — only for background / global events. Mutation feedback uses ActionButton / FormError, not toasts.

import { toast } from 'sonner';
toast.error('Connection lost. Reconnecting...'); // SSE failure, not a click
toast.success('Import complete: 1,247 contacts'); // background job done
toast.error('Session expired. Please sign in.'); // app-level auth

Do

  • Use for events the user didn’t click (SSE reconnect, background job finished, auth expired).
  • Use for app-level notifications that don’t belong next to a specific button.

Don’t

  • Don’t toast mutation results tied to a click — that’s <ActionButton>.
  • Don’t toast form submission errors — that’s <FormError> or inline.
  • Don’t toast validation errors — those live next to the field.
  • Don’t toast.error() inside utility functions — throw and let the component catch.

Component selection decision tree

“I need to show a status.”

  • Dot + label inline → StatusBadge
  • Chip with text → Badge
  • Full-row warning → Alert

“I need to confirm a destructive action.”

  • Routine, per-row, reversible → ConfirmPopover
  • Catastrophic, entity-wide → ConfirmDialog

“I need to collect input.”

  • Yes/no that saves immediately → Switch
  • Yes/no in a form → Checkbox
  • 1-of-2-to-4 → RadioGroup
  • 1-of-N (N≥4) → Select
  • Text → Input / Textarea
  • Number range → Slider
  • Password → password-input

“I need to show a list.”

  • Structured rows with columns → Table or DataTable
  • Cards in a grid → CardGrid
  • Entities with avatars → custom row using <Avatar> + <Card>

“I need to show a mutation’s status.”

  • Button-triggered → ActionButton
  • Form submission error → FormError
  • Standalone inline status → MutationStatus
  • Background event (SSE, import) → toast

“I need a dropdown.”

  • 1-of-N input → Select
  • Action menu → DropdownMenu
  • Command palette → cmdk-based custom (see WorkflowCommandPalette for reference)

“I need a side panel.”

  • Slide-in from the right → Sheet
  • Settings split → SplitContent
  • Chat panel → the shell-level ChatPanel

“I need to reveal extra content on demand.”

  • Collapsible section → Accordion or Collapsible
  • Hover detail → Tooltip
  • Anchored panel → Popover

Forbidden patterns (component-level)

NEVERDO
Raw <button> with Tailwind<Button>
Raw <table><Table> or <DataTable>
Raw <input> / <textarea><Input> / <Textarea>
Custom modal with position: fixed<Dialog> or <Sheet>
Custom toggle from <button> + styling<Switch>
<span> with pill styling<Badge>
<div> with border + padding for card<Card> / <CardContent>
Building a dropdown from <button> + <ul><DropdownMenu>
Full-page loading spinner<Skeleton>
toast.error('Failed to save') on click<ActionButton> (inline feedback, see Rule 6)
alert('Are you sure?')<ConfirmPopover> or <ConfirmDialog>
window.confirm(...)<ConfirmPopover> or <ConfirmDialog>
Hardcoded color classes (bg-blue-500)Semantic tokens (bg-primary)

Next: 05-patterns.md.