Ariakit Composite for Custom Grids
Permalink to "Ariakit Composite for Custom Grids"Ariakit is a headless React library of accessible primitives. Its Composite component family implements the focus management behind lists, toolbars and grids: one tab stop, arrow keys between items, rows for two-dimensional movement, optional virtual focus via aria-activedescendant, and typeahead. It does not decide what your widget is — you supply roles (grid, row, gridcell), names and state attributes. That makes it a good base for custom data widgets that existing grid libraries do not fit: card grids, calendars, seat maps, matrix pickers.
This guide belongs to headless UI primitives for data.
Spec reference
Permalink to "Spec reference"useCompositeStore— state: active item, orientation, focus loop, focus wrap, virtual focus, RTL.Composite,CompositeRow,CompositeItem— render props and keyboard handling; rows enable Up and Down between rows.virtualFocus— keeps DOM focus on the composite and setsaria-activedescendant.CompositeTypeahead— typeahead on item text.- ARIA grid pattern — you add
role="grid",role="row",role="gridcell"and names. - Relevant criteria: SC 2.1.1, 2.4.3, 4.1.2 (A), SC 2.4.7 (AA).
When to use — and when not to
Permalink to "When to use — and when not to"Use Composite for custom two-dimensional widgets where a full data grid is too heavy and a list is too simple: card grids, seat or slot pickers, pivot matrix selectors, dashboard tiles with keyboard movement.
Do not use it to build a full spreadsheet; you would reimplement much of what grid libraries already provide. Do not forget roles — a Composite without roles is keyboard-operable but semantically a set of generic elements.
The misapplication: a Composite grid of cards with roving focus but no role="grid" or names, so screen readers announce “clickable” for each card with no structure.
Annotated code example
Permalink to "Annotated code example"import * as Ariakit from '@ariakit/react';
function SeatGrid({ rows, onSelect, selected }) {
const store = Ariakit.useCompositeStore({ focusLoop: false, focusWrap: false });
return (
<Ariakit.Composite store={store} role="grid" aria-label="Seats, row 1 to 20"> {/* name + role (SC 4.1.2) */}
{rows.map((row) => (
<Ariakit.CompositeRow key={row.id} role="row" aria-rowindex={row.index}>
{row.seats.map((seat) => (
<Ariakit.CompositeItem key={seat.id} role="gridcell"
aria-label={`Seat ${seat.id}, ${seat.available ? 'available' : 'taken'}`}
aria-selected={selected.has(seat.id)}
aria-disabled={!seat.available || undefined}
onClick={() => seat.available && onSelect(seat)}
className="seat">
{seat.id}
</Ariakit.CompositeItem>
))}
</Ariakit.CompositeRow>
))}
</Ariakit.Composite>
);
}
.seat[data-focus-visible] { outline: 3px solid var(--focus); outline-offset: 2px; } /* Ariakit sets data-focus-visible */
.seat[aria-selected="true"] { background: var(--selected-bg); box-shadow: inset 0 -3px 0 var(--brand-strong); }
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Behaviour (Ariakit) | Expected announcement |
|---|---|---|
| Tab | Into composite at active item | “Seats…, grid, Seat 12C, available, row 12” |
| Arrows | Move within and across rows | Item name |
| Home / End | Row start / end | Item name |
| Ctrl+Home / Ctrl+End | First / last item | Item name |
| Enter / Space | Your onClick | Selection state (aria-selected) |
| Typing | Typeahead (if enabled) | Matched item |
Integration context
Permalink to "Integration context"The underlying techniques are described in implementing roving tabindex for custom data grids and using aria-activedescendant for grid cell focus. For full data grids, compare with React Aria hooks for custom grids. Listbox versus grid decisions are in listbox versus grid for selectable record lists.
Gotchas
Permalink to "Gotchas"Disabled items. Decide whether disabled items are focusable (often yes, announced as unavailable) — Ariakit supports focusable on disabled items.
Virtual focus and scrolling. With virtualFocus, scroll the active item into view yourself if it is off-screen.
Rows with different lengths. Moving Down from a column that does not exist in the next row needs a rule (nearest column); test it.
Worked example
Permalink to "Worked example"A ticketing product’s seat map was a grid of clickable divs. Keyboard users could not pick seats, and screen reader users heard nothing useful. Rebuilding with Ariakit Composite gave one tab stop, arrow movement across rows and seats, and Home and End per row; the team added role="grid", seat names with availability, aria-selected, and a status message for “2 seats selected, total 84 EUR”. Seat selection became possible by keyboard and screen reader for the first time.
Testing it with a screen reader
Permalink to "Testing it with a screen reader"- Tab into the composite; confirm one tab stop and that the grid’s name and the first item are announced.
- Arrow across a row and down to the next row; confirm item names and positions.
- Select an item; confirm aria-selected is announced and the selection summary is spoken.
- Try a disabled item; confirm it is announced as unavailable (or skipped, by design).
- Repeat with VoiceOver, where grid navigation may require interacting with the grid first.
Wrapping it for product teams
Permalink to "Wrapping it for product teams"A KeyboardGrid wrapper should take label, rows and an itemName(row, item) function, render the composite with grid roles, apply focus and selection styles from tokens, and connect selection changes to the shared announcer. Product code then supplies data and names only, which removes the most common failure: forgotten roles.
Common mistakes
Permalink to "Common mistakes"- Composite without roles.
- Names missing on items.
- Loop and wrap enabled where users expect to stop.
- No focus-visible styles.
Adoption checklist
Permalink to "Adoption checklist"- Wrap Composite usage in a design-system
KeyboardGridcomponent that requires a label and item names. - Default to roving tabindex, no loop, no wrap.
- Provide focus and selection styles from tokens.
- Add a status region for selection summaries.
- Test with NVDA and VoiceOver.
FAQ
Permalink to "FAQ"Is Ariakit Composite a grid?
It is the focus mechanism; you make it a grid by adding grid roles and names.
Should I use virtual focus?
Use it when DOM focus must stay elsewhere (for example, in an input). For standalone grids, roving tabindex is simpler.
Does Composite support typeahead?
Yes, via CompositeTypeahead.
Can I use Composite for toolbars?
Yes; it is commonly used for toolbars, with role=“toolbar”.
How do I handle selection?
Track it yourself and expose aria-selected; announce summaries for multi-selection.
Is Ariakit only for React?
Ariakit’s main package is for React; Ariakit also offers Solid support in development — check current status.
How do I test it?
Testing Library with role queries and keyboard events, plus manual screen reader checks.
What about Page Up and Page Down?
Ariakit composites support page navigation in recent versions for scrollable composites; verify in yours and document it in the keyboard contract.
Can composite items contain buttons?
Prefer items that are themselves the interactive element. If items contain several controls, consider a grid with cells that are entered with Enter, or a different pattern.
Related
Permalink to "Related"- React Aria hooks for custom grids — a grid-specific alternative
- Roving tabindex for custom grids — the technique
- Toolbar pattern for grid actions — another composite