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 sets aria-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).
What Ariakit Composite handles and what you add Layers: Ariakit handles roving tabindex or virtual focus, arrow keys across items and rows, Home and End, typeahead and RTL; you add roles, names, states, styles and announcements. What Ariakit Composite handles and what you addAriakit: focus modelroving tabindex or aria-activedescendantAriakit: keyboardarrows, rows, Home/End, Page keys, typeahead, RTLYou: rolesgrid, row, gridcell (or listbox, toolbar)You: names and stateslabels, aria-selected, aria-disabledYou: styles andmessagesfocus-visible styles; status region
Focus mechanics from the library; semantics and messages from you.

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.

Composite without semantics versus with semantics Comparison: a Composite with only focus management announces generic elements; adding grid roles, names and states gives screen reader users structure, position and meaning. Composite without semantics versus with semantics✗ Focus management onlyArrow keys work"Clickable" announcedNo sense of rows and columnsSelection invisible to AT✓ Focus management plus semanticsArrow keys work"Seat 12C, available, row 12, column 3"Row and column positions announcedaria-selected announced

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
Ariakit Composite options Matrix of options — focusLoop, focusWrap, virtualFocus, rtl, orientation — against their effect and typical setting for grids. Ariakit Composite optionsEffectTypical for gridsfocusLoopWrap from end to startOfffocusWrapWrap between rowsOffvirtualFocusaria-activedescendantOff (roving)rtlMirror horizontal keysPer localeorientationKey mappingBoth for grids
Grids usually stop at edges and use roving tabindex.

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"
  1. Tab into the composite; confirm one tab stop and that the grid’s name and the first item are announced.
  2. Arrow across a row and down to the next row; confirm item names and positions.
  3. Select an item; confirm aria-selected is announced and the selection summary is spoken.
  4. Try a disabled item; confirm it is announced as unavailable (or skipped, by design).
  5. 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"
  1. Wrap Composite usage in a design-system KeyboardGrid component that requires a label and item names.
  2. Default to roving tabindex, no loop, no wrap.
  3. Provide focus and selection styles from tokens.
  4. Add a status region for selection summaries.
  5. 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.

Permalink to "Related"

← Back to Headless UI Primitives for Data