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/uicomponent composes Radix primitives with Tailwind classes (viacn()and occasionalcva). - shadcn is NOT a dependency. We do not run
pnpm dlx shadcn add. Components are vendored as plain.tsxfiles inpackages/ui/src/components/. Adding a new primitive means: pick the right Radix package, copy a minimal scaffold (look atdialog.tsxorpopover.tsxfor 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):
- Import path
- Purpose — what it is and what user problem it solves
- Variants / sizes (where applicable)
- Anatomy — the parts and their roles
- States — default, hover, focus, disabled, error, loading (where applicable)
- Code example
- A11y notes — keyboard, ARIA, screen reader behavior
- Do / Don’t
- 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):
| Component | HTML | Size | Use |
|---|---|---|---|
<H1> | <h1> | 24px | Page title (rare in admin) |
<H2> | <h2> | 20px | Detail page title |
<H3> | <h3> | 16px | Section heading, page bar title |
<H4> | <h4> | 14px | Card title, form section title |
<H5> | <h5> | 12px | Minor heading |
<H6> | <h6> | 12px | Minor heading |
Text — body text with variants:
| Variant | Size | Weight | Use |
|---|---|---|---|
body | 14px | 400 | Default body text |
muted | 14px | 400 | Descriptions, timestamps, metadata |
label | 12px | 500 | Non-form labels, table headers |
caption | 12px | 400 | Small 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
classNamefor layout-only overrides (truncate, max-w, line-clamp). - Use
asChildfor 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 usefont-semibold(600). See 02-foundations.md.
Button
@na/ui/components/button
The most-used component in the system. Primary interactive primitive.
Variants
| Variant | Use |
|---|---|
default | Primary action (Save, Create, Submit) |
outline | Secondary action (Cancel, Filter) |
destructive | Delete, Remove, Irreversible |
ghost | Row actions, low-weight actions |
link | Inline link that looks like text |
Sizes
| Size | When to use |
|---|---|
default | Form submits inside ActionBar, dialog footer |
sm | Page bar, toolbar, section header actions |
xs | Dense toolbars, inline actions |
icon | Square icon-only button |
icon-sm | 32px icon-only |
icon-xs | 24px 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
tooltipthat restates the label — that’s noise.
Do
- Use one primary action per page bar.
- Use
outlinefor Cancel, Reset, secondary filters. - Use
destructiveonly for irreversible actions. - Use
ghosticon-xsfor per-row actions.
Don’t
- Don’t build a button from
<div>+ Tailwind. See AGENTS.md Rule 2. - Don’t use
font-boldon 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.
| Component | Purpose |
|---|---|
ListPageBar | Top bar for List + Form page archetypes |
DetailPageBar | Top bar for Detail archetype (back + tabs) |
PageHeader | Reusable two-line header (title + tabs/search) |
PageContent | The only scroll zone; narrow / spaced props |
ContentSection | Titled content group with actions slot |
ContentToolbar | Left filters / right actions inside content |
SplitContent | Two-column layout with sidebar |
SplitScrollContent | Split layout where each pane scrolls independently |
TwoColumnLayout | Full-page two-column (agent detail split) |
CardGrid | Responsive 2/3/4-column card grid |
StatRow | Horizontal KPI row with dividers |
FormSection | Titled form-field group with space-y-4 |
ActionBar | Sticky bottom bar for Save/Cancel — sibling of PageContent |
DetailHeader | Entity identity card (avatar + name + metadata) |
Separator | The 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. |
RightPanel | Flat right-side detail panel for 3a Split archetype (Plan 2) |
PropertyList / PropertyRow / InlineEditField | Flat key-value rows with click-to-edit (Plan 2) |
CollapsibleSection | Titled section with ▾ chevron, persisted per-user state (Plan 2) |
ViewSwitcher | Table / Kanban / Calendar switcher — task/flow screens only (Plan 2) |
FilterChips | Pill-style filter chips above a collection (Plan 2) |
HoverCardPreview | Anchored popover on entity-name hover (Plan 2) |
InboxList | Inbox archetype: list + preview with j/k/e triage (Plan 2) |
BoardView | Kanban columns + drag-drop + multi-select (Plan 2) |
FocusLayout / Stepper | Focus archetype shell for wizards (Plan 2) |
Navigation
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.
Sidebar / SidebarProvider
@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
SidebarFooterfor 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-4minimum). Vertical padding is already provided by parent’sgap-4, so avoidmt-*on siblings — it stacks on top of the gap and inflates spacing. - For a scrollable body region, use
min-h-0 flex-1on<ScrollArea>(not fixed heights likeh-[calc(100vh-8rem)]— those break when the action bar height changes, and collapse the drawer on short viewports). - A
border-bon a mid-drawer divider must sit inside apx-4container, 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.
DropdownMenu
@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.
Breadcrumb
@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 links — 1, 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
| Prop | Type | Notes |
|---|---|---|
page | number | 0-based page index |
pageSize | number | Items per page |
totalElements | number | Total items across all pages |
totalPages | number | Total pages |
onPageChange | (page: number) => void | Called on chevron click (0-based) |
onPageSizeChange? | (size: number) => void | Optional. When omitted, the page-size Select is hidden. |
pageSizeOptions? | number[] | Defaults to [10, 20, 50]. |
States
| State | Appearance |
|---|---|
| empty | ”No results” replaces the count |
| at first page | Previous chevron is disabled (opacity-50 cursor-not-allowed) |
| at last page | Next chevron is disabled |
| count visible | Range 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"(rendersaria-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+sizeparams.
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
Paginationprimitive retired (2026-04-28). The lower-levelPagination/PaginationContent/PaginationLink/PaginationPrevious/PaginationNext/PaginationEllipsisexports 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-mdHelp text below. ← optional <p className="text-muted-foreground text-xs">States
| State | Appearance |
|---|---|
| default | border-border border, bg-background, text-foreground |
| hover | unchanged (text inputs don’t visually change on hover) |
| focus | ring-2 ring-ring ring-offset-2, border stays |
| disabled | opacity-50 cursor-not-allowed (via the input’s disabled attr) |
| error | border-destructive + <FormMessage> below |
| readonly | same 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’sid. 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-describedbylinking to the help-text<p id>so screen readers read it.
Do
- Always pair with a
<Label>(explicit or via<FormField>). - Use
password-inputfor 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
autoCompletecorrectly —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-boldor 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:
| State | Appearance |
|---|---|
| resize | resize-y by default (vertical drag handle, bottom-right) |
| max-h | If 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:
htmlFor↔idpairing,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-heightwithout 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:
| Variant | Class | Use |
|---|---|---|
| Default | text-sm font-medium | Form labels next to Switch/Checkbox/Slider |
| Stacked above an input | text-sm font-medium | Most form rows |
| Stacked above a Select (Attio) | text-muted-foreground text-xs font-medium | Per 13-section-anatomy § S5 Select row |
| Section sub-heading inside a row | text-xs font-medium uppercase tracking-wide | Rare — group label inside a complex setting |
States
| State | Appearance |
|---|---|
| default | text-foreground |
| disabled | opacity-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
htmlFormatching the control’sid, OR nest the control inside<Label>. - Required labels suffix with
*AND setaria-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-bold—font-mediumis 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
| Part | Class anchor | Purpose |
|---|---|---|
SelectTrigger | h-9 rounded-md border bg-background | The closed-state input-like surface |
SelectValue | inline | Renders the selected value or placeholder |
SelectContent | bg-popover border shadow-md max-h-[var(--radix-select-content-available-height)] | The portal-rendered options panel |
SelectItem | text-sm + hover/selected bg | One option |
SelectGroup | container | Logical grouping |
SelectLabel | text-muted-foreground text-xs font-medium | Group section header — quiet, matches DropdownMenuLabel |
SelectSeparator | bg-border h-px | Divider between groups |
States
| State | Appearance |
|---|---|
| default | border-border bg-background |
| hover (item) | bg-accent text-accent-foreground |
| focus | ring-2 ring-ring |
| selected (item) | trailing <Check> icon, no special bg |
| disabled (item) | opacity-50 pointer-events-none |
| open | trigger 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 includearia-labelif 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
SelectLabelfor group headers — keep weight atfont-medium(500), notfont-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>withcreatableinstead.
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 boxStates
| State | Appearance |
|---|---|
| default | border-border 16px square |
| hover | unchanged (no per-control hover; click target is the whole row) |
| focus | ring-2 ring-ring ring-offset-2 |
| checked | bg-primary fill + <Check> icon |
| indeterminate | dash icon (for “select all” headers when partial) |
| disabled | opacity-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)○ SystemStates
| State | Appearance |
|---|---|
| default | border-border 16px circle |
| focus | ring-2 ring-ring ring-offset-2 |
| selected | bg-primary filled inner circle |
| disabled | opacity-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 primaryStates
| State | Appearance |
|---|---|
| off | bg-input track, thumb left |
| on | bg-primary track, thumb right |
| focus | ring-2 ring-ring ring-offset-2 |
| disabled | opacity-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-checkedautomatically. - 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 thumbStates
| State | Appearance |
|---|---|
| track | bg-secondary (full) + bg-primary (filled portion to thumb) |
| thumb | bg-background border-2 border-primary 16px circle |
| focus | ring-2 ring-ring ring-offset-2 on the thumb |
| disabled | opacity-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-valuetextwith a human phrase (“1.20× speed”, “0 dB”, “70%”). The defaultaria-valuenowreads 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> body | Building a non-row layout (matrix, custom split) |
| The row matches an S* pattern from 13-section-anatomy | The 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
FormProvideraround 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
useStatewhen react-hook-form exists.
Data Display
Table / DataTable / SortableTableHead
@na/ui/components/table · @na/ui/components/DataTable · @na/ui/components/SortableTableHead
Three layers:
| Primitive | Role | When 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 perreferences/attio-design-language.mdL25–31. - Column header. 12px,
text-muted-foreground,font-medium. Set by<TableHead>itself — don’t override. - Numeric columns: pass
numeric: trueon the column descriptor;<DataTable>addstext-right tabular-numsto both header and cell. - Interactive rows get
hover:bg-muted/50 transition-colorsautomatically (baked into<TableRow>). - Whole-row click via
onRowClick.<DataTable>handlesevent.stopPropagation()for the rowActions cell. - Entity-name cells in
cellrenderers 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>exposesselectedIds+onSelectionChangefor 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>):
| Prop | Type | Notes |
|---|---|---|
id | string | Stable column id. Also the sort identifier when sortable: true. |
header | ReactNode | Header label. |
cell | (row: T) => ReactNode | Required. Explicit renderer — no accessorKey shortcut (clearer for non-trivial cells). |
sortable | boolean | Renders the header through <SortableTableHead> with caret + click handler. |
numeric | boolean | Right-align + tabular-nums on header and cell. |
className | string | Applied to header AND cells. |
cellClassName | string | Cell-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; setsisFiltered={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:
emptyMode | What renders | Use 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>CardContentdefaultp-4— don’t override.- Selected state:
ring-2 ring-primary(notborder-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"orrole="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'):
| Variant | When | Shape |
|---|---|---|
blank-slate | User has never had data here | Full size (py-16): icon + title + description + primary CTA (+ optional secondary) |
filtered | Data exists; current filters hide it | Compact (py-8): no icon, no description; just title + a single “Clear filters” action |
permission | User lacks access to this surface | Full size: lock icon + “ask admin” description + “Request access” CTA |
error | Network / fetch failed | Full 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-64for a title,h-64 w-fullfor 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
| Context | Class |
|---|---|
| Inside a button | h-4 w-4 |
| Inside an input | h-4 w-4 |
| Inside a small popover | h-3.5 w-3.5 |
| Inside a list row | h-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-bordercontent belowVariants
| Orientation | Class | When |
|---|---|---|
horizontal (default) | h-px w-full | Between distinct content blocks within one column |
vertical | w-px h-full | Between 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-10is 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-orientationautomatically. - 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-borderfor 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.
| Surface | When | Example |
|---|---|---|
<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> pending | Click-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 spinner | Never. 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 (
disabledReasonon 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.
Carousel
@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-mediumfixed 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/testshistory). - 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) — seereferences/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 inKnowledgeBaseTab.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 itemk/↑— previous iteme— archive currentEnter— open in canonical detail page (leave inbox)⌘Enter— mark read and move to nextEsc— 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 tocloseTo, 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
actionsprop — 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 path | When clean | When dirty |
|---|---|---|
✕ close button | navigate immediately | <ConfirmPopover> via the closeAction prop on <FocusLayout> — anchors to the same ✕ slot |
| Cancel button (in the action bar) | navigate immediately | wrap the Cancel <Button> in a <ConfirmPopover> with the same copy — anchors to Cancel |
| Esc key | navigate immediately | caller-owned — programmatically click the ✕ ref to open the same popover, never silently bail |
| Sidebar click / browser back | navigate immediately | use 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.
- Below —
role="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
pendingTextandsuccessText— the state transition is worth showing. - In form contexts, set
errorPlacement="below"for instant visibility.
Don’t
- Don’t use a plain
<Button>+ manualuseStatewhen 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.
| Symptom | Likely right primitive |
|---|---|
Width escalation: sm:max-w-2xl → max-w-4xl → max-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 body | Focus 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
alignfor 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>) → passalign="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.
- Trigger on the right side of the screen / inside a row’s right gutter → keep the default
Don’t
- Don’t use for catastrophic actions — use
<ConfirmDialog>. - Don’t leave
alignon its default for left-anchored triggers — the popover will visually appear to extend off-screen before Radix flips it. Specifyalign="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 clicktoast.success('Import complete: 1,247 contacts'); // background job donetoast.error('Session expired. Please sign in.'); // app-level authDo
- 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 →
TableorDataTable - 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
WorkflowCommandPalettefor 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 →
AccordionorCollapsible - Hover detail →
Tooltip - Anchored panel →
Popover
Forbidden patterns (component-level)
| NEVER | DO |
|---|---|
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.