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"
Headless primitives for data UIs Matrix of primitives — Ariakit Composite, Radix DropdownMenu, Headless UI Combobox, Downshift, React Aria hooks — against the pattern, what they provide and what you add. Headless primitives for data UIsPatternProvidesYou addAriakit CompositeComposite focusRoving or virtual focus,keysRoles, names, statesRadix DropdownMenuMenu buttonMenu roles, keys, focusreturnRecord names, outcomesHeadless UI ComboboxCombobox + listboxRoles, active descendantLabel, chips, countsDownshiftCombobox / selectProp getters, statusMarkup, chips, gridmessagesReact Aria hooksGrid / tableRoles, keys, selection,sortMarkup, styles, messages
Each primitive owns behaviour; context and semantics gaps are yours.

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.

Choosing a headless primitive Flow: need a full data grid with custom rendering? React Aria hooks. A keyboard grid of items without table semantics? Ariakit Composite with grid roles. Row actions? Radix DropdownMenu. Searchable filters? Headless UI Combobox or Downshift. Choosing a headless primitiveFull custom data gridrows, columns, sorting, selectionReact Aria hooksKeyboard grid of itemsseat maps, tiles, matricesAriakit Composite + rolesRow actionsthree or more actions per rowRadix DropdownMenuSearchable filterlong value listsHeadless UI Combobox or DownshiftAlwaysnames, focus styles, announcementsyour wrapper

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.

A Radix menu inside a React Aria grid cell Sequence among grid, cell, menu trigger and menu: the grid has focus on a cell; Enter moves into the cell's trigger; Enter opens the menu; Escape closes it and returns focus to the trigger; a second Escape returns control to the grid. A Radix menu inside a React Aria grid cellGridCellTriggerMenufocus cellEnter: interact with cellcontentEnter: openEscape: close, focus backEscape: grid navigation resumes
Clear handoffs keep both keyboard models working.

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.

Permalink to "Related"

← Back to Accessible Data Component Libraries