Tangible Design System · @tangible/ui 0.2.29 · 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 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.

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

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

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

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

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.

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

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

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.