# @handamade/psi-react 34 React 19 components consuming @handamade/psi-tokens. Zero runtime deps, CSS Modules, ref-as-prop. ## Machine-readable artifacts - dist/manifest.json: full component/prop inventory with types and defaults - dist/patterns.json: 13 composition patterns (D47) — gap-annotated recipes with clarifying parameters; presets are generated JSX, never hand-written - docs/{Component}.md: per-component docs (props, theming, variant guidance) ## Rules for generated code - import { Button } from "@handamade/psi-react"; import "@handamade/psi-react/styles"; - Also import from @handamade/psi-tokens: base.css, a theme css, components.css, utilities.css. - size is a px number (24|32|40|48), never "sm"/"md"/"lg". - variant vocabulary: accent | accent-subtle | neutral | neutral-subtle | ghost | danger | danger-subtle | outline. - One accent per visual group. danger only for destructive actions. - Before composing multi-component UI, consult dist/patterns.json (or psi-mcp search) for a matching pattern and honor its clarifying parameters; presets are copy-paste JSX generated from the compose tree. - Pattern `content` text in `[square brackets]` is a placeholder to replace with real copy. Never `` or `{braces}` — those do not parse as JSX text, so the build rejects them (D48 class 9). ## Button: href + outline (D33/D34) - Button renders as an when given `href` (plus optional `target`/`rel`); disabled anchors get aria-disabled, no href attribute, and pointer-events: none instead of the disabled attribute. - `outline` is a bordered-ghost variant: visible structure, no fill until hover — good for marketing CTAs and download buttons. - --psi-button-font overrides button typography across all sizes in one declaration; brands can set it via componentOverrides (ember → mono). ## Card - : stacked places media above the body, featured places media beside it (~1.6fr/1fr, stacks under the md breakpoint). hoverLift lifts the card translateY(-6px) over --psi-duration-350. Border uses --psi-border-faint. ## Panel - : the elevated surface panel (D51) — bg-secondary, faint hairline border, radius-12, space-24 padding (16 for compact). Binds the shared --psi-surface-* recipe Dialog's panel also uses; brands retune both by overriding --psi-surface-*. Not a Card: no media slot, no hover lift. ## DescriptionList (2) - `` with one `value` per field (D70). Renders `
`; each item is a `
` group, which is the HTML5 grouping element, so the term/value association survives. - `stacked` (default) puts the term above its value; `inline` is a two-column grid and is what a detail drawer wants. This is the body of the `detail-drawer` pattern — do not hand-roll a `
`. - The term is a prop and the value is children, matching Field's `label` idiom. No `size` prop: this is type, so restyle through `--psi-description-list-*`. ## Toolbar - : horizontal wrapping row for filter/search controls (Input, Select, Tag, Button). Wraps on overflow (D52); with aria-label it announces as role=group. Deliberately not ARIA role=toolbar (no roving tabindex). Unblocks the filter-toolbar pattern — consult dist/patterns.json for the preset. ## NavBar - {links}: brand slot leading, nav links centered/inline, actions slot trailing (theme switch, CTA). Renders inside .psi-container. Background defaults to --psi-scrim-heavy so content can scroll underneath; border uses --psi-border-faint. ## AspectRatio - {children}: a frame div with CSS aspect-ratio set from the numeric ratio prop — wrap images/media to reserve layout space before load. Combine with the .psi-media-tint utility for the brand media-tint treatment. ## Icons (26) - Full icon set: IconPlus, IconMinus, IconCheck, IconClose, IconChevronDown, IconChevronRight, IconSearch, IconSettings, IconUser, IconEye, IconEyeOff, IconEdit, IconCopy, IconTrash, IconLoader, IconExternalLink, plus D36 additions — arrows IconArrowDown and IconArrowUpRight, and social glyphs IconLinkedIn, IconGitHub, IconX, IconInstagram — plus the D64 status glyphs IconInfo, IconAlertTriangle and IconAlertCircle, and the D70 IconMoreHorizontal — the ellipsis glyph for a row-actions trigger. ## Table family (6) - `` renders `
` and is **controlled-only** — it holds no state (D62). Sorting is `sort={{key, direction}}` + `onSortChange`, which is called with the **next** state, already toggled — store it as given (a fresh column arrives `asc`; an active `asc` emits `desc`); selection is `selected` (a `ReadonlySet` keyed by each ``) + `onSelectionChange`. `sortable` and `selectable` only enable the affordances. `size` is `32 | 40 | 48` px (default 40) and `stickyHeader` pins the header. It does no filtering, sorting or slicing of rows — the consumer does that. - ``, ``, `` render ``, ``, ``. `` renders `
`, taking `sortKey` to become sortable and putting `aria-sort` on the ``. `` renders ``. Both cells take `numeric`, which right-aligns **and** renders tabular figures. - Selecting rows: give each `` a `rowId` and a `selectLabel` — the row checkbox has no visible label, so `selectLabel` is its accessible name. `` grows the select-all checkbox automatically; do not add one. Consult dist/patterns.json for the `data-table` pattern. ## Pagination - ``: a numbered pager with ellipsis truncation (`‹ 1 … 4 5 6 … 13 ›`), built from Button/IconButton (D63). `siblingCount` (default 1) sets how many pages flank the current one before truncating; `aria-current="page"` marks the active page and the ellipsis is inert. `page` outside `[1, pageCount]` (including non-finite values) is clamped for rendering and warns in development (D78). - It is **standalone, not a Table family member** and has no connection to `Table` — `Table` has no `page` prop. The `table-pagination` pattern composes it as a `Toolbar` sibling of a page-size `Select`; the consumer owns page state and slices the rows. ## Dialog, and drawers (there is no Drawer component) - ` …} title footer width={400|560|720} dismissible>`: modal on the native `` top layer. **Controlled-only** (D50) — every dismissal path (`"esc"`, `"backdrop"`, `"close-button"`) only *reports* via `onClose(reason)`; the consumer flips `open`. `dismissible={false}` swallows Esc and backdrop, leaving the footer as the only exit. - **A drawer / side sheet is ``, not a separate component (D66).** `placement` is `center | inline-start | inline-end`; the `inline-*` values pin the panel full-height to that edge. Do not import `Drawer` — it does not exist and never will. `width` is then the drawer's width; the height is always the viewport. - Placement changes position and nothing else: modality, the focus trap, `aria-modal`, focus restore and the dismissal reasons are identical. Logical (`inline-*`, not left/right), so RTL flips for free. Consult dist/patterns.json for the `detail-drawer` pattern. ## Toast family (3) + useToast - `message` is presentational and **controlled** (D64): it holds no state, runs no timer and never removes itself — `onDismiss` reports and the owner disposes. Variants are Tag's **status** axis, not the flat action variants; there is deliberately no `accent`. The variant's meaning is announced by a visually hidden status word, so never rely on the icon colour alone. - `` is the positioned container and must wrap every toast. It renders **two always-present live wrappers** (`role="status"` polite, `role="alert"` assertive) and routes each toast by variant — neutral/success polite, warning/danger assertive. It sits on the native top layer via `popover="manual"`, so a toast raised from inside a modal `Dialog` is still painted above the backdrop and still announced — though not clickable until the dialog closes, since `showModal()` makes everything outside the dialog inert. Raise toasts *after* the dialog closes if they carry an `action`. - `` owns the queue, the timers and the single region — the library's **one stateful container** (D65), and opt-in. Inside it, `const toast = useToast()` gives `show({variant, message, action}) => id`, `dismiss(id)` and `clear()`. Timers pause while the pointer or focus is in the region and resume with the time remaining (WCAG 2.2.1); toasts carrying an action get the longer lifetime. - **Do not hand-roll a queue or a `setTimeout`** — that is what `ToastProvider` is for. Do not render a bare `` outside a `ToastRegion`: it would announce nothing. `useToast()` throws outside a provider rather than silently doing nothing. ## Tabs family (4) - `` is **controlled-only** (D67): `value` and `onValueChange` are required, there is no `defaultValue`, and Tabs never selects itself. Compose `` with one `` each, then one `` per tab. - **Values are strings, not indices**, and `Tab`/`TabPanel` pair by value — their source order need not match, though keeping it matching reads better. - **Activation is automatic**: arrow keys move focus and selection together. There is no manual-activation mode. Arrows follow the orientation's axis (Left/Right horizontal, Up/Down vertical) with wrap; Home/End jump to the first/last enabled tab; disabled tabs are skipped and use `aria-disabled`, not the `disabled` attribute. - **Every panel renders; unselected ones get `hidden`.** So `aria-controls` always resolves and panel DOM state survives a switch. If a panel is expensive, render less *inside* it (`{selected ? : null}`) rather than omitting the `TabPanel` — omitting it leaves the tab's `aria-controls` pointing at nothing. - `TabList` requires `aria-label`. The active panel is itself a tab stop, so a panel with no focusable content is still reachable. ## Menu family (3) - ` …} trigger={} placement="bottom-start|bottom-end|top-start|top-end" aria-label="…">`: action menu on the native Popover API — top layer and light dismiss come from the platform, roving keyboard and dismissal reasons from Psi (D53). **Controlled-only** like Dialog: `onClose(reason)` reports `"esc" | "outside" | "item-select"` and the consumer flips `open`. Compose `` and ``; the trigger needs an accessible name, so an icon-only trigger is ``. - Many menus, one state: for a row-actions column, hold a single `openMenuId` and render each row's Menu with `open={openMenuId === row.id}`, closing with `onClose={() => setOpenMenuId(null)}`. Do not give each row its own boolean. This is the shape D58 fixed a real dismissal bug in — switching directly between two menus left both closed, because the platform light-dismisses the first before the consumer's click handler runs. ## Compositions (recipes for what has no component yet) - Form field: wrap the label, control, and message with `{control}` — label association (htmlFor/id), the description/error message line (swaps to error, aria-live="polite"), and aria-describedby/aria-invalid wiring onto a nested Input/Select are automatic (D49). `group` mode renders fieldset/legend for self-labeled controls (Checkbox/Switch). Standalone Input/Select used outside Field still need `aria-invalid` and `aria-describedby` paired manually. Spacing: `psi-gap-24` between fields, `psi-gap-12` for toggle groups, `psi-gap-8` for button rows and label+Tag rows. - Stacks/rows: `.psi-gap-*` utilities set only the gap — they do not create a flex/grid container. Pair them with `display: flex` (column for stacks, row for button groups). - Page-level spacing: the form scale above (24/12/8) governs controls inside a form, not page layout. For layout use `psi-gap-32` between sections of a page, `psi-gap-24` between cards or panels in a grid, `psi-py-32` for a section's vertical padding, and `.psi-container` for the page's max-width and gutters (it is in utilities.css, not components.css). Marketing-scale rhythm is app-level and deliberately not tokenized — the system stops at the container. - Utility classes: families are `psi-gap-*`, `psi-p-*`/`psi-px-*`/`psi-py-*`, `psi-m-*`/`psi-mx-*`/`psi-my-*` (all on the spacing scale), `psi-text-*` and `psi-display-*` (typography), plus `.psi-tabular`, `.psi-container`, `.psi-media-tint` and `.psi-sr-only` (hides content visually while keeping it in the accessibility tree). Scaled utility families set one property each and create no layout context — pair `psi-gap-*` with `display: flex`; the bare/fixed utilities (`.psi-container`, `.psi-media-tint`, `.psi-sr-only`) set several. Use `psi-m-0` to kill a UA margin rather than an inline style. The exact roster is `@handamade/psi-tokens/guidance.json` → `utilities.classes`. - Native pass-through: components spread unlisted props onto their underlying element (Input → ``, Select → `