Tangible Design System · @tangible/ui 0.2.36 · Markdown

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

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:

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.

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 standalone app gets that, and more, from the app base sheet (§6). 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.

Switch themes instantly. Controls transition their colours for hover, so a bare data-theme change makes every button and field fade into the new theme while the page behind snaps. All of these come from @tangible/ui:

An OS change under auto still fades: measured in Chromium, a matchMedia listener fires after the restyle. Spinners keep spinning; looping animations deliberately ignore the marker.

The toggle itself: a Switch with a stable name ("Dark mode"), not a button whose label flips and also carries aria-pressed (it announces twice). Light / dark / system is a SegmentedControl, not a cycling button. Keep focus on the control, and don't toast "Theme changed".

4. Pick the stylesheet by host.

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:

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

Icons have the same problem in JavaScript. Any component that renders an icon pulls in the icon registry, every set, ~145 kB minified / ~30 kB gzipped, because names like "system/check" are looked up at runtime. To ship only the icons you name, point the registry at a lean one of your own:

// src/tui-icons.ts: every icon name YOUR code passes to TUI, and nothing else
import { Check, Plus, Trash } from '@tangible/ui/icons/system/index.js';
export const iconRegistry = { 'system/check': Check, 'system/plus': Plus, 'system/trash': Trash };
// vite.config.ts: TUI imports the registry by a relative path, so match the specifier's tail
resolve: { alias: [{ find: /icons\/registry\.js$/, replacement: '/src/tui-icons.ts' }] }
// webpack.config.js
plugins: [new webpack.NormalModuleReplacementPlugin(/icons\/registry\.js$/, path.resolve('src/tui-icons.ts'))]

TUI's own icons (chevrons, close, check, …) are imported directly from 0.2.30, so they never need listing. To find the names your code passes, search for every <set>/<name> string literal, single- or double-quoted, not just icon=: Button takes leftIconName/rightIconName, and names also arrive through icon fields in data (NavMenu items, Tabs, StepList), e.g. rg -o "['\"](system|lms|cred|reaction|player|membersync)/[a-z0-9-]+" src | sort -u. A name you use but forget to list renders an empty <span data-tui-icon-missing="…"> and a dev-mode warning. Or skip names altogether: every icon prop also takes the component itself, <IconButton icon={Trash} label="Delete" />, which never touches the registry. Import each icon from the set that owns its file: the lms/info, lms/help-circle-fill and lms/question-mark aliases live in system. The alias is verified on Vite production builds; the webpack line is the equivalent, untested.

Or deliver icons as a sprite (opt-in through 0.2.x; it becomes the default at 1.0). Alias the registry to @tangible/ui/icons/sprite-registry.js instead, the same way. Every name then renders <svg><use href="#tui-system-check"/></svg>, and the icon shapes leave your JavaScript. The page has to contain the symbols, one set at a time (system is 10 kB gzipped; the others 2–5 kB each).

In WordPress, print them with the page: no fetch, no flash of empty icons. node_modules doesn't ship in a plugin zip, so copy sprite.php and the <set>.svg files from node_modules/@tangible/ui/icons/ into your build output first:

require_once __DIR__ . '/assets/tui-icons/sprite.php';
add_action('admin_footer', fn () => tangible_ui_print_icon_sprite(['system']));

Elsewhere, insert them from JavaScript before rendering icons. Don't await it: icons fill in when their set lands, and it never rejects (it resolves with { loaded, failed }), so a CDN blip can't stop the app from rendering:

import { injectIconSprite } from '@tangible/ui/icons/sprite';
// Vite: pass the files as ?url imports, since the dev server can't serve them from a pre-bundled dep
import systemUrl from '@tangible/ui/icons/system.svg?url';
void injectIconSprite({ sets: ['system'], spriteUrl: () => systemUrl });

6. A standalone app that owns the whole page can add the app base sheet. For Vite/React apps, not plugin frontends:

import '@tangible/ui/styles';
import '@tangible/ui/styles/app';

It expects .tui-interface on <html> or <body>, with data-theme on either, and sets what every Tangible app had been hand-rolling:

App typography. Copy reads at 14px (--tui-typography-size-sm) on every interface, portals and nested scopes included (on <body> when <html> is the interface, so rem stays 16px). Controls that set their own font size keep their ladder; em-sized pieces inside copy, such as an inline Icon or a bare Checkbox, follow the copy. The roles below use weights 400 and 600 only. The sheet retunes headline-04 to 600 / 1.25 and body/body-sm to line height 1.4, and sets Field help to fg-muted for every Field in the app, so it matches app helper text. Pick the element from the document outline, never from the size (page title h1, card title h2 is a sensible default):

Role Class Colour
Page title tui-text-style-headline-04 (24 / 600) default
Card or section title tui-text-style-headline-07 (16 / 600) default
Page, card and empty-state description; key labels tui-text-style-body-sm --tui-color-fg-muted
Body and value text tui-text-style-body-sm default
Setting-row title, inline emphasis, identity name body-sm at weight 600 default
Helper, meta, caption; secondary line of a two-line row tui-text-style-caption (12) muted (built in)
Nav group header tui-text-style-overline; write the copy in sentence case, the style uppercases it muted (built in)
Stat value 20 / 600 / 1.4 in the body face default
Stat trend 12 / 400, coloured by sentiment, not arrow: --tui-theme-{success,danger,secondary}-stronger
Empty-state title 16 / 600 / 1.4 in the body face default

A brand heading face goes on --tui-text-headline-family. It reaches every headline level, so page and card titles, and nothing in the body face (stat values and empty-state titles stay body). Field help on a light bg-muted panel measures 4.39:1 in fg-muted, under AA, so set --tui-field-helper-fg: var(--tui-color-fg-secondary) on that panel.

It sits in the defaults layer at zero specificity, so TUI's components win over it and any unlayered rule of yours wins over everything in it. It is layered only (there is no unlayered build), which is why it isn't for WordPress. Fonts and page layout stay yours. Don't re-roll screen-reader-only text or skip links: .tui-visually-hidden and .tui-visually-hidden--focusable already work when <body> is the interface.

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.

<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:

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).

Translating TUI's built-in strings

TUI ships English defaults for every string it renders or announces on its own. Strings you pass in (label, aria-label, titles) are already yours. Wire the rest to your translation layer (@wordpress/i18n, tangible-strings) or the UI is partly English. Verified against main, Oct 24 2026.

labels objects (pass a partial; unset keys keep the default). The defaults are exported as default<Name>Labels; nine come from the barrel, the rest from @tangible/ui/components/<Dir>.

Component Keys
Avatar / AvatarGroup description / group, groupAll, overflow
Button newTab (" (opens in new tab)", the hidden hint on target="_blank" links)
Calendar prevMonth, nextMonth, prevYear, nextYear, openMonthPicker, day, today, selected
ColorField trigger, dynamic, modes, solidTab, gradientTab (its popover takes ColorPicker's)
ColorPicker group, area, hue, alpha, format, value, copy, copied, variable, formats, channels, channel names, value texts
Combobox clear
DataTable / BulkActionsBar selectAll, selectRow, pageStatus, sortBy, sorted / selected, actions
DatePicker title, titleWithTime, now, close, placeholder, timeSection, dateSection, month, dayOfMonth, year, hour, minute, am, pm, meridiem, trigger, triggerEmpty
DateRangePicker from, to, apply, cancel, placeholder, presets, trigger, triggerEmpty
Field required (the visually hidden "required" after the label)
GradientPicker group, bar, stop, value, copy, copied, reverse, stop editing, type and geometry labels
MoveHandle moveUp, moveDown, drag, locked (no exported default object)
MultiCombobox clear, remove, status
MultiSelect selected, more, status
NavMenu submenu
Pager previous, next, first, last (+ *Text), page, navigation, pageStatus, itemsPerPage, perPage, jumpLabel, jumpText
Rating rating, group, value
SearchSelect search, clearSearch, noResults
SecretInput reveal, hide, shown, hidden, valueSet, valueSetDescription, locked
Sidebar.Toggle collapse, expand
StepIndicator notStarted, inProgress, complete, locked, status, statusWithValue, withAttention
StepList the same minus statusWithValue, plus current (status-only lists); status defaults to the bare word, since the row's label names the step

Some keys are functions (counts, page status, value texts); the type says which.

Flat string props: Chip removeLabel ("Remove"); ChipGroup multipleLabel; DataTable loadingMessage, emptyMessage; BulkActionsBar clearLabel; Modal closeLabel; Notice and Toast dismissLabel ("Dismiss") and dismissedMessage ("Notification dismissed", announced after the × closes it); SearchInput searchLabel, clearLabel; Select clearLabel; ToastProvider regionLabel ("Notifications").

Composed strings: Button loading names itself ", loading" in English. Pass loadingLabel with the translated whole.

Customising

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

/* 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:

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

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…07, 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.

A tooltip that repeats the trigger's name is type="label". The default tooltip is a description: it puts aria-describedby on the trigger, so a screen reader hears "Settings, button, Settings" when the text matches the aria-label. <Tooltip type="label"> adds no ARIA and hides its text from the accessibility tree; the trigger must carry its own name (a dev warning fires if it doesn't). Use it only when the tooltip says the same words as the name: extra information is a description, and label would hide it from screen readers. IconButton showTooltip and Avatar's tooltip already do this. Don't wrap the trigger in a <span> to dodge the describedby. Set --tui-tooltip-max-width (default 250px, never wider than the viewport) for a one-line tooltip, rather than a className.

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.

Card spacing, container and links

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.

/* 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:

.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:

<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 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 / side sheet / off-canvas panel → Sidebar (<Sidebar drawer open onClose position="left">: a modal dialog with a focus trap, Escape and backdrop dismissal; initialFocusSelector or data-autofocus picks where focus lands on open; don't dock a Modal to an edge), 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:

Icons are a registry, six sets, 262 entries (Oct 24 2026): system (123), lms (45), reaction (36), player (24), membersync (22), cred (12). @tangible/ui/icons/manifest is the machine-readable list. Search it across all sets, by name and by aliasOf: an alias names the same glyph a second way (system/refresh and system/rotate are system/arrow-counterclockwise). General-purpose glyphs live in system (system/info, system/help-circle-fill, system/question-mark); their old lms/ keys still work as aliases.

<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.

And one in layout: a DataTable inside a wrapper that can't shrink never scrolls. A grid or flex item that is not itself a scroll container (a Card, a <section>) keeps min-width: auto, so it can't shrink below the table's width, and the page widens instead. That holds for an auto track and a bare 1fr track alike. Use grid-template-columns: minmax(0, 1fr), or put min-width: 0 on the item. A DataTable placed directly in the track is fine: its root is the scroll container.

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.

<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>

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.

House patterns, by app type

Decisions Julia has made for a kind of app, recorded so the next app of that kind starts from them. They are house style for that app type, not TUI rules: check the app type before applying one.

Public single-purpose web app

A public React app with one job and a handful of screens: HushNote (tangible-hush, hush.tangible.one) is the reference. <body class="tui-interface"> with data-theme on <html>, and tangible-strings' brand.css.

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.