Headless UI Primitives for Data
Permalink to "Headless UI Primitives for Data"Headless libraries — Ariakit, Radix UI, Headless UI, Downshift, React Aria — implement the hard parts of ARIA patterns (focus management, keyboard models, attribute wiring) and leave appearance to you. For data UIs they are the building blocks of custom widgets: keyboard grids for seat maps and matrices, row action menus, filter comboboxes, and fully custom data grids when off-the-shelf grids do not fit. Their promise is “accessible by default”; the reality is “accessible if wired correctly and given the context only you have”.
This topic covers five primitives for data UIs and the gaps each leaves: roles and names (Ariakit Composite), record-specific triggers and destructive flows (Radix menus), labels and chips (Headless UI and Downshift comboboxes), and complete structural wiring (React Aria hooks). The users affected are everyone who uses the resulting widgets — especially screen reader users (roles, names, announcements) and keyboard users (focus models and visible focus). The failure this topic prevents is a custom widget built on an accessible primitive that is still inaccessible because a role, a name or a prop getter was missed.
This topic belongs to accessible data component libraries.
WCAG criteria in scope
Permalink to "WCAG criteria in scope"| Criterion | Level | What primitives provide | What you add |
|---|---|---|---|
| 1.3.1 Info and Relationships | A | Some roles (menus, comboboxes, grids via hooks) | Roles for generic composites; labels |
| 2.1.1 Keyboard | A | Keyboard models | Domain shortcuts |
| 2.4.3 Focus Order | A | Focus return in menus and dialogs | Focus after data changes |
| 2.4.7 Focus Visible | AA | Focus-visible state | Styles |
| 4.1.2 Name, Role, Value | A | States and relationships | Names with record context |
| 4.1.3 Status Messages | AA | Downshift option counts | Data result messages |
Prerequisites
Permalink to "Prerequisites"- The ARIA patterns themselves: composite widget roles & states, row actions & context menus and accessible filtering & faceted search.
- Focus and announcement basics from focus management in single-page apps and ARIA live regions for dynamic data.
ARIA & HTML spec reference
Permalink to "ARIA & HTML spec reference"| Primitive | Key attributes it manages | Attributes you must supply |
|---|---|---|
| Ariakit Composite | tabindex or aria-activedescendant |
role, aria-label, aria-selected |
| Radix DropdownMenu | aria-haspopup, aria-expanded, menu roles |
Trigger aria-label with record |
| Headless UI Combobox | role="combobox", aria-controls, aria-activedescendant |
Visible label; option text |
| Downshift | All combobox and listbox attributes via getters | Elements to spread them on |
| React Aria hooks | Grid roles, indices, aria-sort, aria-selected |
Markup and styles |
Step-by-step implementation
Permalink to "Step-by-step implementation"Step 1 — Match the primitive to the pattern
Permalink to "Step 1 — Match the primitive to the pattern"Keyboard grid of items → Composite; row actions → DropdownMenu; searchable filter → Combobox or Downshift; full custom grid → React Aria hooks.
Step 2 — Wire every part
Permalink to "Step 2 — Wire every part"<ul {...getMenuProps()}>{items.map((it, i) => <li {...getItemProps({ item: it, index: i })}>…</li>)}</ul>
See Downshift for autocomplete filters.
Step 3 — Supply semantics
Permalink to "Step 3 — Supply semantics"<Ariakit.Composite store={store} role="grid" aria-label="Seats">…</Ariakit.Composite>
See Ariakit Composite for custom grids.
Step 4 — Names with record context
Permalink to "Step 4 — Names with record context"<button aria-label={`Actions for ${invoice.id}`}>⋯</button>
See Radix DropdownMenu for row actions.
Step 5 — Labels and chips
Permalink to "Step 5 — Labels and chips"<Field><Label>Customer</Label><Combobox multiple …/></Field>
See Headless UI Combobox for filters.
Step 6 — Full grids from hooks
Permalink to "Step 6 — Full grids from hooks"const { gridProps } = useTable({ 'aria-label': 'Invoices' }, state, ref);
See React Aria hooks for custom grids.
Keyboard interaction contract
Permalink to "Keyboard interaction contract"| Primitive | Keys handled | Your additions |
|---|---|---|
| Ariakit Composite | Arrows, Home/End, Page keys, typeahead | Enter/Space actions |
| Radix DropdownMenu | Enter/Space/Down to open; arrows; Escape; typeahead | Focus after row removal |
| Headless UI Combobox | Typing, arrows, Enter, Escape | Chip removal keys |
| Downshift | Typing, arrows, Enter, Escape; selected item keys | Grid announcements |
| React Aria hooks | Full grid model | Domain shortcuts |
Screen reader compatibility matrix
Permalink to "Screen reader compatibility matrix"| Primitive | NVDA | JAWS | VoiceOver | Notes |
|---|---|---|---|---|
| Ariakit Composite (with roles) | Good | Good | Good | Roles are essential |
| Radix DropdownMenu | Good | Good | Good | Portal reading order |
| Headless UI Combobox | Good | Good | Good | Combobox announcements vary by reader |
| Downshift | Good | Good | Good | Status message configurable |
| React Aria hooks | Good | Good | Good | Same as Components |
Edge cases & failure modes
Permalink to "Edge cases & failure modes"1. Composite without roles
Permalink to "1. Composite without roles"Diagnosis: keyboard works; screen readers hear generic elements. Fix: grid, row and gridcell roles with names.
2. Generic menu triggers
Permalink to "2. Generic menu triggers"Diagnosis: “More actions” repeated. Fix: record-specific names.
3. Missing prop getters
Permalink to "3. Missing prop getters"Diagnosis: combobox relationships broken. Fix: spread every getter; always render the menu.
4. Duplicate status messages
Permalink to "4. Duplicate status messages"Diagnosis: Downshift and app both announce counts. Fix: one source per message.
5. Hook wiring skipped at one level
Permalink to "5. Hook wiring skipped at one level"Diagnosis: invalid grid structure. Fix: wrap hooks in internal components; test with role queries.
Ariakit Composite for custom grids
Permalink to "Ariakit Composite for custom grids"Focus mechanics for keyboard grids of items; add roles, names, states and selection summaries. See Ariakit Composite for custom grids.
<Ariakit.CompositeItem role="gridcell" aria-label={`Seat ${seat.id}, available`} />
Radix DropdownMenu for row actions
Permalink to "Radix DropdownMenu for row actions"The menu button pattern with focus return; add record names, correct item types, confirmations and outcomes. See Radix DropdownMenu for row actions.
<DropdownMenu.CheckboxItem checked={flagged}>Flag for review</DropdownMenu.CheckboxItem>
Headless UI Combobox for filters
Permalink to "Headless UI Combobox for filters"A combobox with listbox; add visible labels, counts in options, chips and result announcements. See Headless UI Combobox for filters.
<ComboboxOption value={c}>{c.name} ({c.invoiceCount} invoices)</ComboboxOption>
Downshift for autocomplete filters
Permalink to "Downshift for autocomplete filters"Prop getters for full markup control, a configurable status, multi-select via useMultipleSelection. See Downshift for autocomplete filters.
getA11yStatusMessage: ({ isOpen, resultCount }) => isOpen ? `${resultCount} tags available` : ''
React Aria hooks for custom grids
Permalink to "React Aria hooks for custom grids"The full grid model with your markup; wire every level and style focus and selection. See React Aria hooks for custom grids.
const { gridCellProps } = useTableCell({ node: cell }, state, ref);
Cross-cutting concerns
Permalink to "Cross-cutting concerns"Wrappers. Product teams should rarely use primitives directly. Design-system wrappers supply names, roles, styles and announcer integration once.
Mixing primitives. A grid built with React Aria hooks may host a Radix menu and a Downshift combobox in cells. Keep focus models compatible: the grid owns arrow keys until a cell’s widget is activated with Enter, and Escape returns control to the grid.
Versions. Headless libraries evolve quickly; pin versions in wrappers and run accessibility tests on upgrades.
Server rendering. Most primitives support SSR; IDs must be stable between server and client (use useId) or ARIA relationships break after hydration.
Worked example: a custom planning board
Permalink to "Worked example: a custom planning board"A planning tool needed a matrix of teams by weeks, each cell holding allocations with actions, plus filters by team and skill. Off-the-shelf grids did not fit the layout. The team used React Aria hooks for the matrix (grid roles, keyboard, selection), Radix menus inside cells for allocation actions (record-named triggers, outcomes announced), and Downshift for skill and team filters (labels, chips, concise status), all wrapped in design-system components with shared focus tokens and announcer. Screen reader testing showed the planning board behaving like a well-built data grid, despite its custom layout.
Who is affected, and how
Permalink to "Who is affected, and how"| User group | Typical failure with primitives | Fix |
|---|---|---|
| Screen reader users | Missing roles, generic names | Roles and record names |
| Keyboard users | Conflicting keyboard models | Clear handoffs; Escape returns |
| Low-vision users | Unstyled focus | Focus tokens |
| Voice control users | Unnamed icon triggers | Visible or accessible names |
| Everyone | Silent data changes | Announcements |
Evaluating a headless primitive
Permalink to "Evaluating a headless primitive"Before adopting a primitive, check which ARIA pattern it implements and how completely: roles, states, keyboard model, focus management, typeahead, RTL. Read its accessibility documentation and open issues. Then build the smallest version of your data widget with it and test with NVDA and VoiceOver for twenty minutes. Note what you had to add — roles, names, announcements, styles — and estimate how much of that a wrapper can absorb. A primitive whose gaps are all wrapper-shaped is a good choice.
An audit script for widgets built on primitives
Permalink to "An audit script for widgets built on primitives"Search for direct primitive imports in product code (Composite, DropdownMenu, Combobox, useCombobox, useTable). For each usage, check in the accessibility tree: role, name with context, state attributes and focus styles. Then exercise the keyboard model, including nested widgets. Direct usages that fail are prime candidates for moving to design-system wrappers.
Migration path from hand-rolled widgets
Permalink to "Migration path from hand-rolled widgets"Hand-rolled menus, comboboxes and grids are common sources of accessibility bugs. Replace them with primitives in order of impact: row action menus (simple swap, big gains), filter comboboxes (complex behaviour, many bugs), then custom grids (largest effort, largest gains). Each replacement should delete the old keyboard handling entirely rather than layering the primitive on top of it.
Design system integration
Permalink to "Design system integration"Build design-system components on primitives rather than exposing primitives directly: KeyboardGrid (Ariakit), RowActions (Radix), FacetFilter and Autocomplete (Headless UI or Downshift), CustomGrid (React Aria hooks). Each requires the props that prevent common failures (labels, record names), applies tokens for focus and selection, and integrates the announcer. Document which primitive underlies each component so maintainers can track upstream changes.
Common mistakes with headless primitives
Permalink to "Common mistakes with headless primitives"- Treating “headless accessible” as “done”.
- Skipping roles or prop getters.
- Generic names for repeated controls.
- No visible focus styles.
- Mixing keyboard models without handoffs.
Measuring quality
Permalink to "Measuring quality"For each component built on a primitive, track: roles and names verified in the accessibility tree, keyboard contract tests passing, focus styles verified against tokens, announcements present for data changes, and upgrade test date. Components that miss any check should carry a known-issue note in documentation.
A reference stack for a data product
Permalink to "A reference stack for a data product"One coherent stack many teams settle on: React Aria (Components where possible, hooks for custom grids) for tables and grids; Radix for menus and dialogs; Downshift or Headless UI for comboboxes; a shared announcer; design tokens for focus, selection and density; and wrappers that expose a small, accessible API. The specific libraries matter less than the consistency: one primitive per pattern, wrapped once, tested once.
Testing checklist
Permalink to "Testing checklist"Automated
Permalink to "Automated"Manual
Permalink to "Manual"FAQ
Permalink to "FAQ"Are headless libraries accessible out of the box?
They implement behaviour correctly. Names, roles for generic composites, styles and data-level announcements are still yours.
Which primitive library should we standardise on?
React Aria covers the most patterns, including grids; Radix and Headless UI are popular for menus and comboboxes; Ariakit is strong for composites. Many teams mix them behind wrappers.
Can we mix libraries in one widget?
Yes, with clear keyboard handoffs and consistent focus styles.
Do primitives handle RTL?
Most support RTL via locale or direction props; verify arrow-key behaviour.
How do we keep up with upgrades?
Pin versions in wrappers, read changelogs for accessibility changes and run tests on upgrade branches.
Should product teams use primitives directly?
Rarely. Wrappers prevent repeated mistakes.
Are these primitives React-only?
Radix, Ariakit, Downshift and React Aria are React-focused; Headless UI has Vue support. Angular has the CDK, covered in the Angular topic.
What about Vue equivalents?
Headless UI (Vue), Radix Vue and Reka UI provide similar primitives; the same principles apply.
Do primitives replace accessibility testing?
No. They reduce the number of things that can go wrong; you still test names, flows and announcements in context.
How do primitives interact with design tokens?
Primitives expose state (focus-visible, selected, disabled) as data attributes or render props; style those states with tokens in wrappers.
Can primitives be used in server-rendered pages?
Yes; ensure IDs come from useId so ARIA relationships survive hydration.
What is the biggest risk when adopting primitives?
Assuming accessibility is complete. Budget time for names, roles, styles and announcements in every widget.
How do we document which primitive a component uses?
Note it in the component’s documentation and changelog, with links to the primitive’s accessibility docs, so maintainers can track upstream changes and users can find expected behaviour.
Are there primitives for charts?
Not in the same sense; chart accessibility comes from chart libraries and wrappers, covered in the chart library topic.
Related
Permalink to "Related"- React data grid libraries — full grids
- Design system tokens & component contracts — wrappers and contracts
- Composite widget roles & states — patterns
- Accessible filtering & faceted search — filters
- Row actions & context menus — menus