# Building with TUI

> When to read this: Use when writing, reviewing or testing React UI that imports @tangible/ui (TUI) — components render unstyled or unthemed, a portalled Modal/Select/Popover loses its tokens or its theme, dark mode does not follow the host app, a prop or type will not resolve, a label or error is not announced, or you need to know which component (or token) to reach for.

Consumer-side skill: how to build an app **with** `@tangible/ui`. (Building TUI itself is covered by
the repo's own CLAUDE.md and `.claude/agents/*`.)

The API is on disk and does not need guessing. What costs hours is the **wiring and the a11y
plumbing**, because both fail silently rather than erroring. That is most of what follows.

Inventory figures (component counts, manifest coverage, subpaths, icons, `data-testid` pass-through)
were re-measured against **0.2.28** (Oct 13 2026). Other sections were last verified against
**0.2.11 + main** (Aug 22 2026) unless a claim carries its own date. Re-check anything that reads as
surprising.

## The minimum that works

```tsx
import '@tangible/ui/styles';            // once, app-wide — Wiring §4 picks the right entry
import { Button } from '@tangible/ui';

<div className="tui-interface" data-theme="dark">   {/* omit data-theme for light */}
  <Button label="Save" theme="primary" />
</div>
```

Both halves are load-bearing. Without `.tui-interface` there are no tokens; without the stylesheet
there is no CSS at all.

## Where the API lives

The docs are also fetchable as plain markdown, no JS needed: <https://storybook-tangible-ui.pages.dev/llms.txt> indexes
every guide and one page per component (props, defaults, API tokens); `llms-full.txt` is all of it in one file. Prefer it over
the Storybook UI when you can only fetch URLs.

`node_modules/@tangible/ui/` — read in this order, stop when answered:

| Question | File |
| - | - |
| install, style entries, wrapper, dark mode, token overrides | `README.md` |
| **props, types, JSDoc — the authority** | `components/<Dir>/types.d.ts`, falling back to `components/<Dir>/<Dir>.d.ts` |
| is there a `.Content` / `.Option` / `.Trigger`? | the compound type in `components/<Dir>/<Dir>.d.ts` |
| what tokens does this component read? | `tui-manifest.json` → `components.<Name>.cssTokens` |
| which Storybook page is this? | `tui-manifest.json` → `components.<Name>.story.title` |

Directory and file name match, with two exceptions: `Table/DataTable.d.ts` and
`ColorPicker/ColorField.d.ts`. Props live in `types.d.ts` for 35 of 46 components; the other eleven
declare them in the component file (Card, Chip, Chips, ColorPicker, Field, GradientPicker, Icon,
Modal, Notice, OverlapStack, Toast).
`<Dir>.d.ts` is still the better read for the **compound shape** — it spells out
`type SelectCompound = … & { Trigger; Content; Option; Group; Label }` in one place, which the
barrel's flat export list only implies.

**Do not use `tui-manifest.json` for props.** It is curated for narrative, not completeness, and
the filter drops load-bearing API:

- It keeps exactly five `on*` props — `onClick`, `onChange`, `onClose`, `onDismiss`,
  `onValueChange` — and drops every other callback. `onCheckedChange`, `onOpenChange`,
  `onInputChange`, `onSearch`, `onClear`, `onRemove`, `onPageIndexChange`, `onSelectionChange`
  are all absent.
- Any prop without a JSDoc comment is dropped, required or not. The clearest tell: the manifest's
  **`Checkbox` entry lists `rel` and `title` but not `checked`, `defaultChecked`, `indeterminate`
  or `onCheckedChange`** — the entire state API is missing while inherited HTML noise survives.
- 39 components report `subcomponents: null`, and for 13 of those the compound API is real
  (Select, MultiSelect, SearchSelect, Combobox, MultiCombobox, Dropdown, Popover, Tooltip, Field,
  Sidebar, StepList, Toolbar, NavMenu) — [#87](https://github.com/TangibleInc/tangible-ui/issues/87).
- Only 8 of 46 entries carry a `description`, and 4 carry `use`/`notFor`, so it is a thin guide to
  *which* component too. The two `guides` entries (Tabs vs SegmentedControl, Chip vs Button) are
  worth reading; the rest of that judgement is not in the file.

**Manifest silence is not absence.** What it *is* good for: `cssTokens` (43 of 46 components list
their full `--tui-<component>-*` API — the only shipped listing, since consumers have no SCSS) and
`story` (all 46, pointing into <https://storybook-tangible-ui.pages.dev>).

### Types that will not resolve from the barrel

`@tangible/ui` re-exports the main `*Props` types but systematically omits others. Import those from
`@tangible/ui/components/<Dir>` — all 46 component subpaths are exported.

- **Nearly every `*Labels` type** (the i18n prop): `ButtonLabels`, `FieldLabels`,
  `ComboboxLabels`, `MultiSelectLabels`, `RatingLabels`, `DataTableLabels`, `AvatarLabels`,
  `StepIndicatorLabels`, `CalendarLabels`, … (`PagerLabels` is the exception — exported since
  0.2.12.)
- **Most union / variant aliases**: `AccordionVariant`, `ContentIndicatorSize`,
  `ComboboxFilterMode`, `IconButton`'s `Shape`/`Variant`, `NoticeHandle`, `ProgressSegment`,
  `OptionValue`, `TooltipTheme`, … (`SidebarFooterProps` was on this list until 0.2.18, which
  also exported the new `SidebarToggleProps`.)
- **All of DatePicker** — the barrel exports zero DatePicker types: `DatePickerProps`,
  `DateRangePickerProps`, `CalendarProps`, `DateRange`, `DateRangePreset`, `WeekStart`.
- **`SliderProps`**, which is missing entirely.

Also: `@tanstack/react-table` is a real dependency of TUI, not a peer, so it installs
automatically — but under pnpm it has no root symlink, so `ColumnDef` will not resolve from app
code. Use `DataTableProps<T>['columns']` instead.

## Naming conventions — so you don't guess

| Concern | Convention |
| - | - |
| value change | `onValueChange` (not `onChange`, except native HTML wrappers like Textarea) |
| boolean state | `onCheckedChange` (Checkbox, Switch) |
| floating panels | `open` / `defaultOpen` / `onOpenChange` |
| ARIA | kebab-case string props only — `'aria-label'`, never `ariaLabel` |
| size | `size?: 'sm' \| 'md' \| 'lg'` on form controls; Button/Icon/Chip use wider or narrower scales |
| `theme` | semantic colour — `primary`, `secondary`, `success`, `warning`, `danger`, `info`; the accepted subset varies (`ThemeIntent` on Button, `ThemeStatus` on Notice) |
| `variant` | visual treatment — `solid`, `outline`, `ghost`, `link` |
| `mode` | structural mode (Progress `line \| circle`, MoveHandle `full \| handle \| arrows` — `arrows` + `index` is the no-drag position badge) |
| classes | BEM-ish and stable: `tui-select__trigger`, `is-size-sm`, `is-disabled` |

Clearing a clearable control calls back with **`undefined`**, not `''`. Controlled-vs-uncontrolled
is locked at mount, so a `value` that becomes `undefined` does not silently flip the component to
uncontrolled — but your own state has to tolerate `undefined`.

Buttons take either `label` (simple text) or `children` (rich content).

## Wiring (each of these fails silently)

**1. `.tui-interface` is mandatory.** Every token is defined in one `:where(.tui-interface){…}`
block. Without that ancestor, components render with no design tokens at all.

**2. Portalled UI resolves its host from the trigger.** Modal, Select, Dropdown, Popover and
Tooltip portal via `getPortalRootFor(trigger)`, which walks **up from the trigger** to the nearest
`.tui-interface` and appends a portal root there. So a trigger inside your wrapper needs nothing.
The failure case is a trigger that has no `.tui-interface` ancestor: TUI then creates its own
body-level wrapper and *snapshots* `data-theme` from `<html>`/`<body>` once, at creation. It never
re-reads it, so a host that toggles theme after mount gets stale portalled content. Wrapping your
JSX does **not** fix a Modal rendered outside the tree — the dialog is not in that subtree.

Only **Modal** takes a `container` prop. If you need to place it yourself, create a body-level
`.tui-interface` div, keep `data-theme` live on it, and pass it as `container` — attaching in a
`useLayoutEffect`, because Modal resolves its container in a layout effect and focusing a detached
node silently does nothing. For the rest of the family, the fix is to put the *trigger* inside a
`.tui-interface`. `getPortalRootFor` is importable from `@tangible/ui/utils/portal` if you need to
resolve the same root yourself.

If `<html>` itself is the `.tui-interface`, portal roots mount inside `<body>`, not on `<html>`.

**3. Dark mode is `data-theme`, not a class.** TUI's palette is gated on `[data-theme="dark"]`
(`"auto"` follows `prefers-color-scheme`; absent means light and inherits the host's colour). With `data-theme="dark"`/`"auto"` on an ancestor such as `<html>` and an un-themed `.tui-interface` on `<body>`, TUI surfaces (panels, cards, filled controls) render correctly because they own their text colour, but in-flow text directly inside the interface still inherits the HOST's colour by design — so set the host's own body colour (e.g. `color: var(--tui-color-fg)` on `body`) if you want TUI's. A host
that toggles a `.dark` class (Tailwind's strategy) drives nothing in TUI. Translate it: observe what
is actually applied to the document — a `MutationObserver` on `<html>`'s `class` catches boot,
explicit toggles and OS changes alike — and mirror it onto your wrapper. Resolve to explicit
`light`/`dark` rather than TUI's `auto`, or an explicit light choice on a dark OS will be wrong.

**4. Pick the stylesheet by host.**

- Host with **its own CSS layers** (Tailwind v4, or any `@layer` author): `@tangible/ui/styles/unlayered`.
  TUI declares `@layer resets, defaults, components, utilities` — `components` and `utilities`
  collide with Tailwind's own names, layers merge across stylesheets, so bundle order would decide
  cascade order for *both*. If TUI won that race, the host's preflight would sort after its own
  utilities and start overriding them app-wide.
- **WordPress / unlayered theme CSS**: `@tangible/ui/styles/unlayered` too — layered CSS loses to
  unlayered theme styles.
- Otherwise the default `@tangible/ui/styles`.

Consequence of unlayered: inside `.tui-interface`, TUI beats host utility classes. Style TUI
elements with `--tui-*` tokens and TUI utilities only — a host utility class there silently loses.

**5. Import the stylesheet once, app-wide** (entry or global CSS), not per component: `all.css` is
\~270 kB raw / \~30 kB gzipped and lands in whichever chunk first reaches a component. Two ways down
if that matters: `@tangible/ui/styles/core` (\~44 kB — tokens, resets, utilities, icon, no
components), or, if your build compiles Sass, the documented subset entry:

```scss
@use "@tangible/ui/styles/index" with (
  $components: (button, checkbox, radio, notice, modal),
  $layers: false
);
```

## Labels and errors: `Field` composition

`Field` wires `id` / `aria-labelledby` / `aria-describedby` / `aria-invalid` / `aria-required`
onto its control by cloning it. Get the composition wrong and the control loses its accessible name
or its error — visually identical either way.

```tsx
<Field error={!!err} required>
  <Field.Label>Course</Field.Label>      {/* MUST precede Field.Control */}
  <Field.Control><Select …>…</Select></Field.Control>
  <Field.HelperText>Pick one</Field.HelperText>
  <Field.Error>{err}</Field.Error>
</Field>
```

Three rules, all of them silent when broken:

- **`Field.Label` before `Field.Control` in JSX.** `aria-labelledby` is injected only when a label
  has already rendered — a phantom `aria-labelledby` would resolve to the empty string and strip
  *all* other name sources (AccName 1.2 step 2B), so TUI withholds it rather than risk that. A dev
  warning fires on the wrong order; production is just unlabelled.
- **Self-labelling controls omit `Field.Label` entirely.** Checkbox and Switch with their own
  `label` prop already have a name; adding `Field.Label` makes AT concatenate both.
- **Group controls need `Field.Label as="span"`.** RadioGroup and SegmentedControl render
  `role="radiogroup"` on a `<div>`, which is not a labelable element, so the default
  `<label for>` dangles. The span keeps the id, so the `aria-labelledby` wiring is untouched.

Not every control accepts every injected attribute. `aria-required` is role-gated: the triggers that
are plain `<button aria-haspopup="dialog">` (SearchSelect, DatePicker, DateRangePicker) resolve to
`role="button"`, which does not allow it — axe flags `aria-allowed-attr`. TUI deliberately does not
declare it there; the requirement still reaches AT through `Field.Label`'s visually-hidden "required"
text. If axe reports `aria-allowed-attr` on a TUI trigger, it is TUI's to fix, not yours
(ColorField currently passes it through — verified Aug 22 2026, not yet filed).

## Customising

Tokens, not class overrides. Four layers, and you only touch two:

```css
/* Primitives — the theme contract. Set on .tui-interface or any ancestor. */
.tui-interface { --tui-radius-md: 2px; --tui-focus-ring-color: hotpink; }

/* Component API — never defined by TUI, only read. Set anywhere that wins. */
.compact-form { --tui-select-trigger-radius: 2px; --tui-accordion-padding: 8px; }
```

`--_*` is component-private and `$_*` compiles away; neither is yours to set. Names come from
`tui-manifest.json` → `cssTokens`.

Every layer-1 primitive — colour roles **and** the scale (spacing, radius, type sizes, weights,
shadows) — is emitted at `:where(.tui-interface)`, specificity (0,0,0). A site theme written at
`:where()` after TUI's sheet therefore overrides all of them uniformly; before 0.2.21 the scale
block sat at bare `.tui-interface` and such a theme won the colours but silently lost the layout
tokens.

Colour maths ships as public utils: `@tangible/ui/utils/color` (`parseColor`, `formatColor`,
`rgbToOklch`/`oklchToRgb`, `rgbToHsl`, `hexToRgba`, …), `@tangible/ui/utils/color/gradient`
(`parseGradient`/`formatGradient`), and `@tangible/ui/utils/color/contrast` (`getContrastTone`,
`getContrastForeground`, `contrastRatio(hexA, hexB)` — WCAG ratio 1–21, `NaN` on bad input so a
`>= 4.5` check fails closed). Use them for user-selected colours and theme tooling (deriving or
validating a ladder), not to pick a component's own theming at runtime.

Useful primitives:

- Surfaces / text: `--tui-color-bg`, `-bg-surface`, `-bg-elevated`, `-bg-muted`, `-bg-inverted`,
  `--tui-color-fg`, `-fg-secondary`, `-fg-muted`, `-fg-on-accent`, `--tui-color-border`,
  `--tui-color-border-control` (form-control boundaries, 3:1; `--tui-color-border` is decorative), `--tui-color-divider`, `--tui-color-fill{,-subtle,-strong}`, `--tui-color-focus-ring`.
- Themes: `--tui-theme-{primary,secondary,success,warning,danger,info}-{subtlest,subtle,soft,base,strong,stronger,strongest}`.
  The ladder is **contrast-based** — "stronger" always means more contrast against the current
  background, so a single choice reads correctly in both light and dark mode.
- Control heights, shared by every form control *and* Button / IconButton / SegmentedControl /
  Pager: `--tui-control-height-{sm,md,lg}` (32 / 40 / 48px, plus an `xs` at 24px that only
  Button / IconButton / SegmentedControl offer — there is no `xs` form-control tier). Add
  `--tui-control-font-size-{sm,md,lg}` to decouple type size from the height tier. One override
  normalises a whole host context at once.

Two families share a token layer, so you can restyle the whole family in one place:

- **Control chrome:** `--tui-input-{bg,fg,border,border-focus,border-hover,radius}` reaches TextInput,
  SearchInput, the pickers and every select-family trigger.
- **Listbox panels and options** (Select, MultiSelect, Combobox, MultiCombobox, SearchSelect;
  0.2.23+):
  - Panel: `--tui-listbox-{bg,fg,border,shadow,radius}`.
  - Options: `--tui-listbox-option-{radius,gap,bg-hover,fg-hover,bg-selected,fg-selected,bg-selected-hover,check}`.

Form controls show no border change on hover unless you set `--tui-input-border-hover` (opt-in; focus, open and invalid still win). A component's own token (e.g. `--tui-combobox-option-bg-selected`) overrides the shared one for
that component only. If you re-point an option state's background, set its `-fg-*` partner too:
the text defaults to the panel's own fg, which is tuned for TUI's default fills.

**Text styles** (0.2.24+) are classes: `.tui-text-style-{display-01…04, headline-01…06, body, body-sm, label, caption, overline}`. Restyle a treatment everywhere through its one class (`.tui-text-style-overline { text-transform: none }` reaches every listbox group label and the Dropdown header), or through its knobs, `--tui-text-{style}-{family,size,weight,line-height,tracking,transform,color}`. Knobs are never declared, so `:root` works. `--tui-text-headline-weight` retunes every headline level at once.

**Fonts:** TUI inherits the host's font. If your page has none (you see the browser's serif), add `tui-font-sans` next to `tui-interface`, or set `--tui-font-family-base` anywhere. `--tui-font-family-sans` / `-mono` replace the stacks those utilities use.

**Tooltips warm up in groups:** wrap a set of tooltips in `TooltipProvider` (a `Toolbar` does it for you), and after the first shows, the rest appear at once while you move between them (`skipDelayDuration`, default 300ms). Don't hand-roll delay logic per tooltip.

`className` is for layout integration (margin, grid placement), not appearance. Beyond the token
API the sanctioned escape hatch is to **swizzle** — copy the component source and own the fork.

### Styling one member of a group

Selection components expose their state as attributes, so you can style a *particular* option
without threading a `className` through every item. `SegmentedControl.Item`, `Tabs.Tab` and `Radio`
carry `data-value` (the item's own value) and `data-state` (`active` / `inactive`), plus
`data-disabled` when disabled; `data-state` is the same vocabulary Accordion, Modal, Sidebar and
DataTable already use.

```css
/* Grading control: failing red, passing green — only when chosen. */
.tui-segmented__item[data-value='fail'] {
  --tui-segmented-item-bg-active: var(--tui-theme-danger-subtlest);
  --tui-segmented-item-fg-active: var(--tui-theme-danger-stronger);
}
.tui-segmented__item[data-value='pass'] {
  --tui-segmented-item-bg-active: var(--tui-theme-success-subtlest);
  --tui-segmented-item-fg-active: var(--tui-theme-success-stronger);
}
```

On `SegmentedControl`'s **outline** variant the selected *border* is the whole selection
indicator, and it has its own token — `--tui-segmented-item-border-active` (added 0.2.17). Before
that it read the theme token directly, so the fill and text were overridable per value and the
border was not. If you recolour a segment in the outline variant, move the border with the fill or
the option will read as the wrong colour outlined in the theme's:

```css
.tui-segmented__item[data-value='fail'] {
  --tui-segmented-item-bg-active: var(--tui-theme-danger-subtlest);
  --tui-segmented-item-fg-active: var(--tui-theme-danger-stronger);
  --tui-segmented-item-border-active: var(--tui-theme-danger-base);
}
```

Check the border you pick against the **page**, not against the fill — it is a non-text indicator,
so WCAG SC 1.4.11 asks for 3:1 there, and a tone that looks right on the fill can fall under that
against a dark page. TUI's own defaults are 3.60:1 (dark) and 7.52:1 (light).

Reach for the component's own tokens inside the rule, as above, rather than setting `background`
directly — that keeps the hover and focus treatments the component already derives. Note that
`--tui-segmented-item-bg-active` only applies when the item *is* active, so the selector above does
not need `[data-state='active']` as well; add it when the property you are setting is not already
state-scoped. Token names come from `tui-manifest.json` → `cssTokens`.

**Recolour the foreground with the background.** The pairing above is `-subtlest` fill with
`-stronger` text — the two-sided pairing Chip already uses, and both halves move with the theme.
Setting only the fill to a `-base` tone leaves the default white-on-accent foreground behind, which
clears 4.5:1 in light mode and then fails it in dark, because the dark ladder lightens: danger drops
to 3.7:1 and success to 3.4:1. The ladder cannot rescue this on its own — "stronger" means more
contrast against the *page*, so in dark mode it moves the fill towards the text, not away from it.

All three items accept `aria-label` and `aria-labelledby` as declared props, so naming one from
visible text elsewhere is supported — but `aria-labelledby` **replaces** the item's own label
rather than adding to it, and an id that matches nothing empties the name completely. Point it at
an element that exists, and check the name you actually get.

`Radio` puts these on the wrapper `<div class="tui-radio">`, so a rule can reach the label and
description too. `Tabs.Tab` also carries `data-tui-tab-value`; that one is internal (Tabs reads it
back for focus lookup) — style against `data-value`.

### Replacing a trigger with `asChild`

`Select.Trigger`, `Dropdown.Trigger`, `Popover.Trigger`, `Tooltip.Trigger`, `Accordion.Trigger`,
`MultiSelect.Trigger`, `DatePicker.Trigger` and `DateRangePicker.Trigger` all take `asChild`,
rendering your element in place of theirs.

The two pickers render their trigger automatically when the root has no children, so
`<DatePicker />` is unchanged. Reach for `DatePicker.Trigger` when you want to place the trigger
yourself — which is what lets the whole popover (time field, Now, month/day/year, calendar) hang off
a text field the user can also type into freely:

```tsx
<DatePicker withTime onChange={(date) => setText(format(date))}>
  <TextInput
    value={text}
    onChange={(e) => setText(e.target.value)}
    suffix={
      <DatePicker.Trigger asChild>
        <IconButton icon="lms/calendar" label="Pick a date" variant="ghost" size="sm" />
      </DatePicker.Trigger>
    }
  />
</DatePicker>
```

TUI never writes into the field: `onChange` hands you a `Date` and you decide what string goes in,
which is why this works with formats TUI knows nothing about ("3pm ET" survives being typed).
Children with no `Trigger` among them leave the picker unopenable — a dev warning fires.

**Size a slot button one tier below the field** (`md` field → `sm` button). Button and IconButton
anchor to `--tui-control-height-*`, which is the *whole field's* height; TUI cancels the group's
padding so it no longer inflates the field, but a same-tier button still fills the content box edge
to edge.

Four things change versus a picker's default trigger, all deliberate:

- **Your element owns its accessible name**, so name it for the action ("Pick a date"). The default
  trigger appends its value text to the name; `asChild` cannot, because there is no value span —
  and a dangling `aria-labelledby` id would resolve the *entire* name to empty.
- **No `tui-input-group` chrome** is applied. That is the point (a bare button, not a bordered
  field) and it is where the pickers diverge from `Select.Trigger`, which force-applies the class
  even under `asChild`. It also means `aria-invalid` still lands on your element but paints nothing,
  so an invalid trigger inside a `Field` needs one rule of your own:

  ```css
  .my-date-trigger[aria-invalid='true'] {
    border-color: var(--tui-theme-danger-base);
    color: var(--tui-theme-danger-base);
  }
  ```

  Everything else `Field` injects works unchanged — `Field.Label` reaches the accessible name, and
  `Field.Error` is announced via `aria-describedby`.
- **`disabled` arrives as `aria-disabled` + `data-disabled`**, since your element may not be a
  `<button>`. The popover refuses to open either way, in the open direction only — closing is never
  blocked, so disabling a form mid-submit cannot strand an open popover.
- **Your element must be natively focusable and keyboard-activatable** — a `<button>` or `<a>`, not
  a styled `<div>`. No key handler is attached under `asChild`; it relies on the child turning
  Enter/Space into a click, which only real interactive elements do.

Your child's `onClick` still runs; call `preventDefault()` in it to suppress the toggle.

**`Accordion.Trigger asChild` is the one that paints.** By default it stamps `tui-accordion__trigger`
on your element — the full-width row look (flex, `width: 100%`, padding, hover background, inset
focus ring), which is what a custom header row wants. If your child is already a styled control (an
`IconButton` chevron in a repeater row), pass `unstyled` so it keeps its own sizing and focus ring;
`data-tui-accordion-trigger` and `data-state` still land, so you can style off the behaviour without
the paint. A native `<button>` child activates on Enter/Space through the browser, once — TUI does not
synthesise a second toggle. For a second control that toggles the same item (an Edit/Close link),
`useAccordionItem()` returns `{ isOpen, panelId, toggle, … }` for its own `aria-expanded`/
`aria-controls`. Controlled single-mode accordions take `value={null}` for "nothing open" —
`undefined` would flip them uncontrolled.

## Component names, and what isn't there

46 components. If you are hunting for a name: **Alert → `Notice`**, **Menu → `Dropdown`**,
**Table → `DataTable`**, **Pagination → `Pager`**, **Drawer → `Sidebar`** (drawer mode), **Nav menu / sidebar links / top nav bar → `NavMenu`** (inside `Sidebar.Nav` or your `<nav>`; `asChild` for router links, `current` for `aria-current`; `orientation="horizontal"` for a bar whose items open flyouts, `flyoutTrigger="hover"` to also open on mouse hover; `suffix` for a row badge/count — a `Chip`, with visually hidden context for bare numbers, never interactive; row states restyle via `--tui-nav-menu-item-*`, per level via `--tui-nav-menu-{sub,bar,flyout}-item-*` — set `bar-item-*` for a dark header so flyout rows keep the panel's colours; don't hand-roll menus with `role="menu"`),
**Badge → `Chip`** or `ContentIndicator`, **Spinner → `Progress`** or `Button loading`,
**ConfirmDialog / AlertDialog → `Modal role="alertdialog"`** (needs both `aria-labelledby` and
`aria-describedby`; the backdrop stops dismissing by default, Escape still cancels; put the actions in
`Modal.Foot`, which is already an end-aligned flex row — `justify="between"` separates a destructive one).
**Snackbar → `Toast`** (`ToastProvider` + `useToast()`).
**Password / API key / token field → `SecretInput`** (a `TextInput` variant: masked, with a reveal
toggle whose name flips Show/Hide; `isSet` is a boolean so a stored secret never reaches the client,
it shows a "Saved" placeholder and typing replaces it; `locked` makes it read-only with no toggle). No Breadcrumb, no Skeleton, and no *standalone* time picker — but
`DatePicker` / `DateRangePicker` take `withTime` for an hour/minute field (12- or 24-hour via
`hour12`) plus a display-only `zoneLabel`; the value is a plain `Date` with wall-clock semantics,
so timezone interpretation stays yours.

Two clusters are easy to pick wrong:

- **Chip family** — `Chip` is one chip. `ChipGroup` is a *selectable* set with radio/checkbox
  semantics (`value` / `onValueChange` / `multiple`). `Chips` is a *display* list rendered from a
  `ChipOption[]` array, with link support and no selection.
- **Select family** — `Select` (single, not typeable), `Combobox` (single, the input *is* the
  trigger, you own the filtering via `inputValue`/`onInputChange`), `SearchSelect` (single, a button
  trigger with the search field *inside* the panel), `MultiSelect` (multi, button trigger, count or
  chips in the trigger), `MultiCombobox` (multi, free-typing token field). All five share one
  listbox engine, so navigation and ARIA behave identically; the trigger is the choice.

Icons are a registry, six sets, 255 entries (Oct 13 2026): `system` (116), `lms` (45), `reaction`
(36), `player` (24), `membersync` (22), `cred` (12). `@tangible/ui/icons/manifest` is the
machine-readable list.

```tsx
<Icon name="system/check" />                {/* decorative — hidden from AT automatically */}
<Icon name="system/lock" label="Locked" />  {/* informative — label makes it announced */}
```

There are no device icons (monitor / tablet / phone) in any set.

## Testing hooks

`data-testid` pass-through is uneven, and the split is not the one you would guess. These spread
rest props onto their primary element: **Button, Card, Checkbox, Chip, ColorPicker, ColorField,
`Field`'s slots, GradientPicker, IconButton, Modal, NavMenu (root, `.Group`, `.Sub`, `.Item`),
Notice, SearchInput, SearchSelect, SecretInput, Switch, Textarea, TextInput, Toast**, **DataTable** (onto the `<table>`), and — since 0.2.16 — the group members
**SegmentedControl.Item, Tabs.Tab and Radio**. Everything else drops them silently,
including several that look like they would take them — Dropdown, Popover, Tooltip, Select,
MultiSelect, Combobox, MultiCombobox, Sidebar, Toolbar, Pager, Rating, Progress,
Avatar, Icon, Slider, MoveHandle, StepList, DatePicker.

Two notes on those three. **Radio spreads onto the wrapper `<div class="tui-radio">`, not the
control its ref targets** — the wrapper is where `className` already goes, and it is the element
holding the label and description. And on all three the spread is applied *before* the component's
own attributes, so a forwarded `role` or `aria-checked` cannot desynchronise the group from what it
announces; the props types omit those, so TypeScript rejects them rather than dropping them.

Typecheck will not save you here: TypeScript exempts **hyphenated** JSX attributes from excess
property checking, so `<Select data-testid="x" />` compiles clean against a prop type that has no
such prop and then never reaches the DOM. (Same mechanism makes `aria-hidden` on `<Icon>` a silent
no-op — `Icon` neither declares nor spreads it. Icons are decorative by default; that is what the
missing `label` already means.) A misspelled *non*-hyphenated prop does error, and `IconName` is a
literal union, so `<Icon name="system/typo" />` fails the build.

The check, since that list will drift: grep the component's built file in
`node_modules/@tangible/ui/components/<Dir>/` for `...rest`. If it isn't there, target the stable
class (`tui-select__trigger`) or the ARIA role instead.

DataTable rows are the exception worth knowing: per-row props go through `getRowProps(row)` —
`data-testid`, refs, `tabIndex`, handlers — and it composes with controlled pagination
(`pageIndex` / `onPageIndexChange`, 0-based).

One live trap there: **memoize `data`, or pass `autoResetPageIndex={false}`.** A derived array gets
a new identity every render, TanStack queues a page-0 reset on every `data` identity change, and
with a controlled `pageIndex` that races your parent back to page 0 permanently.

## Vanilla modal (no React)

For templates and plugin PHP with no React build (Tangible Blocks, L\&L): `@tangible/ui` ships a framework-free modal on the native `<dialog>`. It replaces MicroModal; there is no compat layer, so the markup differs.

```html
<link rel="stylesheet" href="https://unpkg.com/@tangible/ui/vanilla/styles.css"> <!-- or all.css, not both -->
<script src="https://unpkg.com/@tangible/ui/vanilla/modal.min.js" defer></script>

<button type="button" data-tui-modal-open="intro">Watch</button>

<dialog id="intro" class="tui-interface tui-modal" aria-labelledby="intro-title">
  <div class="tui-modal__dialog is-size-lg">  <!-- required: a click on the <dialog> itself is the backdrop -->
    <div class="tui-modal__head"><h2 id="intro-title">Intro</h2></div>
    <div class="tui-modal__body"><div class="tui-modal__body-inner">
      <iframe class="tui-modal__embed" title="Intro video" data-tui-modal-src="https://www.youtube.com/embed/ID?autoplay=1"></iframe>
    </div></div>
    <div class="tui-modal__foot">
      <button type="button" class="tui-button" data-tui-modal-close>Close</button>
    </div>
  </div>
</dialog>
```

- **Attributes.** `data-tui-modal-open="<id>"` and `data-tui-modal-close` go on any element (delegated, so markup added later works; `aria-disabled` triggers are ignored). On the dialog, `role="alertdialog"` (add `aria-describedby`) and `data-tui-modal-backdrop="dismiss" | "static"`; alertdialog defaults to static. `[autofocus]` / `[data-autofocus]` picks initial focus. A submit button or link carrying `data-tui-modal-close` keeps its default action. `data-tui-modal-restore-focus="<id>"` on the dialog names where focus goes on close, for when the opener is gone (a Delete-row confirm); otherwise it returns to the opener. Put `.tui-modal__close` last in the box: initial focus skips it unless it's the only control.
- **State and events.** The script sets `data-state` to `open`, `closing` or `closed`. `tui-modal-open` and `tui-modal-close` bubble from the dialog; close's `detail.returnValue` is the submitting button's value in a `<form method="dialog">`. `window.TangibleUI.modal` exposes `open(idOrDialog)` and `close(...)`.
- **Embeds.** `data-tui-modal-src` loads on open and is dropped on close, which stops a YouTube or Vimeo player with no `postMessage` script. `<audio>`/`<video>` pause. Always give the iframe a `title`.
- **Name it.** The dialog needs `aria-labelledby` pointing at an element that exists (a phantom id leaves it unnamed) or an `aria-label`, and an icon-only close needs its own `aria-label` (the script can't see that one). The script warns in the console about a missing or phantom name, a dangling `aria-describedby`, an alertdialog without `aria-describedby`, a missing `.tui-modal__dialog` box and no close control; warnings are always on.
- **Two enqueues are fine.** If Blocks and L\&L both load the script, only the first copy installs.
- **Limit: the top layer.** A native modal puts everything else inert and renders above it. A React Select, datepicker or any widget portaled to `<body>` renders under the dialog and can't be used inside it. Keep such controls in the dialog's own markup.

## Known issues

Each is tracked in the repo, so **follow the link for current state rather than trusting this
list** — an issue closes on its own, a bullet here does not. This section used to enumerate bugs
inline and went stale within a week; one of its entries turned out never to reproduce at all.
Re-verify before you work around anything.

- **`DateRangePicker` cannot express an open-ended range** — Apply is inert unless *both* endpoints
  are set, so "starts now, no end date" is unreachable.
  ([#86](https://github.com/TangibleInc/tangible-ui/issues/86))
- **`tui-manifest.json` is lossy** — see "Where the API lives" above for the shape of it.
  ([#87](https://github.com/TangibleInc/tangible-ui/issues/87))

## Verify

`tsc --noEmit` catches guessed props immediately — TUI ships types, and most unions are literal.
It cannot see any of the wiring above.

What typecheck misses, in the order it bites:

1. Open a portalled component (Modal or Select) **in dark mode**. That single check exercises the
   portal host, the theme bridge and the token scope at once — it is where all three break.
2. Tab to each control and confirm the accessible name and the error text are announced (the
   `Field` rules above). A browser a11y inspector or axe is enough; both failures are invisible.
3. Narrow the layout. `Card`, `Notice` and `Pager` respond to their **container**, not the
   viewport, so shrinking the window proves nothing if they sit in a fixed-width parent — resize
   the parent instead.

## Reference consumers

`tangible-autism-certify` (graded A — the reference), `tangible-cron-manager-pro`,
`tangible-datastream`, `tangible-searchsync` — all WordPress plugins on roller with `react: 'wp'`
(React 18). `tangible-app` is the first React 19 consumer, so React 19 incompatibilities surface
there first. Read one before inventing a pattern.
