useTableA11y
Standalone opt-in hook returning ARIA prop-getters implementing the WAI-ARIA Grid pattern, plus keyboard navigation.
Standalone opt-in hook. Returns prop-getter objects to spread onto your table elements, implementing the WAI-ARIA Grid pattern.
Signature
function useTableA11y<TData extends RowData>(
table: TablecraftTable<TData>,
options?: UseTableA11yOptions
): TableA11yReturnOptions
| Option | Type | Description |
|---|---|---|
selectionEnabled | boolean | Set to true when row selection is enabled on the table. Adds aria-selected to row props. |
cellNavigation | boolean | Opt in to cell-level (2D) keyboard navigation. Defaults to false, which keeps the row-level behaviour described below. |
Return
| Property | Type | Description |
|---|---|---|
getTableProps | () => { role: 'grid', 'aria-rowcount': number, 'aria-colcount': number } | Spread onto the table element |
getHeaderProps | (headerId: string) => { role: 'columnheader', 'aria-sort': 'ascending' | 'descending' | 'none' } | Spread onto each <th> |
getRowProps | (rowId: string) => { role: 'row', ref, 'aria-rowindex': number, 'aria-selected'?: boolean, 'aria-expanded'?: boolean, tabIndex: 0 | -1, onKeyDown } | Spread onto each <tr> |
getCellProps | (columnIndex: number, rowId?: string) => { role: 'gridcell', 'aria-colindex': number, ref?, tabIndex?: 0 | -1, onKeyDown? } | Spread onto each <td>. Pass rowId to enable cell navigation. |
focusedRowIndex | number | null | Current focused row index — useful for custom focus ring styling |
focusedCell | { rowIndex, columnIndex } | null | Focused cell when cellNavigation is on, otherwise null. Row index is page-relative. |
Usage
import { useTable, useTableA11y } from '@marvinackerman/tablecraft'
const { table } = useTable({ data, columns })
const a11y = useTableA11y(table, {
selectionEnabled: true, // adds aria-selected to row props
})
// Spread onto your elements:
<table {...a11y.getTableProps()} />
// role="grid", aria-rowcount, aria-colcount
<th {...a11y.getHeaderProps(header.id)} />
// role="columnheader", aria-sort="ascending"|"descending"|"none"
<tr {...a11y.getRowProps(row.id)} />
// role="row", aria-rowindex, tabIndex, onKeyDown (ArrowUp/Down/Home/End)
// aria-selected (when selectionEnabled), aria-expanded (when expandable)
<td {...a11y.getCellProps(cellIndex)} />
// role="gridcell", aria-colindex
// Current focused row index (for custom focus ring styling):
a11y.focusedRowIndex // number | nullRow navigation (the default — applied via onKeyDown on row props):
| Key | Action |
|---|---|
ArrowDown | Move focus to next row |
ArrowUp | Move focus to previous row |
Home | Move focus to first row |
End | Move focus to last row |
Enter / Space | Toggle row selection (when selectionEnabled) |
Focus moves for real: spread the returned ref onto the element so the hook can
move DOM focus as the roving tabindex changes. Mounting a grid never steals
focus — only a keypress moves it.
Cell navigation
role="grid" promises two-dimensional keyboard navigation. Pass
cellNavigation: true and give getCellProps the row id to provide it:
const a11y = useTableA11y(table, { cellNavigation: true })
<tr {...a11y.getRowProps(row.id)}>
{row.getVisibleCells().map((cell, columnIndex) => (
<td key={cell.id} {...a11y.getCellProps(columnIndex, row.id)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>The roving tabindex moves from the row to the cell, so exactly one cell in the whole grid is tabbable at a time and the row handler goes inert (otherwise a keypress bubbling from cell to row would move twice).
| Key | Action |
|---|---|
ArrowRight / ArrowLeft | Move one column, clamped at the edges |
ArrowDown / ArrowUp | Move one row, keeping the column |
Home / End | First / last cell in the current row |
Ctrl + Home / End | First / last cell in the grid |
PageDown / PageUp | Move ten rows, clamped |
Enter / Space | Toggle selection of the focused cell's row (when selectionEnabled) |
Row indices are page-relative because you can only focus a row that is actually
rendered. aria-rowindex, by contrast, is the position within the full row set,
so screen readers announce "row 12 of 50" correctly on page 2 rather than
restarting at 1.
Standalone ARIA helpers
The following functions back useTableA11y internally and are also exported
directly, in case you're computing ARIA attributes outside the hook (e.g. for
a custom prop-getter or a non-hook rendering path):
function getAriaSortValue(headerId: string, sorting: SortingState): 'ascending' | 'descending' | 'none'
function getAriaRowCount<TData extends RowData>(table: TablecraftTable<TData>): number
function getAriaColCount<TData extends RowData>(table: TablecraftTable<TData>): number
function getAriaRowIndex<TData extends RowData>(rowId: string, rows: Row<TablecraftFeatures, TData>[]): number| Function | Description |
|---|---|
getAriaSortValue(headerId, sorting) | aria-sort value for a header cell. 'none' when the column is not currently sorted. |
getAriaRowCount(table) | Total number of filtered rows — used for aria-rowcount. |
getAriaColCount(table) | Number of visible leaf columns — used for aria-colcount. |
getAriaRowIndex(rowId, rows) | 1-based row index of the given rowId within the provided rows array. Returns -1 if the row is not found (should not happen in normal usage). The hook passes it getPrePaginatedRowModel().rows, so the index covers the full sorted set rather than the current page. |