React table keyboard navigation — ARIA grid
A table with a hundred cells should not be a hundred tab stops. Set
cellNavigation and the table becomes one tab stop whose interior is
reachable by arrow keys, with correct ARIA grid semantics and a screen reader
that says where you are.
Related: Accessible React data table · Cell editing · Virtualization · Columns
Copy and cut, the way a spreadsheet reads it
Section titled “Copy and cut, the way a spreadsheet reads it”Ctrl/Cmd+C copies the selected rectangle as tab-separated text — the format Excel, Google Sheets, Numbers and LibreOffice all read — so a paste lands in columns rather than one cell. Cells carrying a tab, a newline or a quote are quoted the way those applications quote them.
Values resolve exactly as an export’s do, exportValue included, so a copy and
a downloaded file cannot disagree about what a cell contains.
Ctrl/Cmd+X copies the same text and then calls onCellCut(range). The table
clears nothing itself: what “cut” removes is your decision, and a cut that
emptied cells before the clipboard accepted them would lose the data outright.
Either way the outcome is announced — labels.gridRangeCopied on success,
labels.gridRangeCopyFailed when the browser refuses (the Clipboard API needs a
secure context and can be denied). With nothing selected, both keys are left to
the browser.
Headless: clipboardRangeText builds the text and writeClipboardText writes
it, reporting whether it landed rather than throwing.
Pasting a spreadsheet back in
Section titled “Pasting a spreadsheet back in”Ctrl/Cmd+V parses what a spreadsheet put on the clipboard and commits it as
ordinary cell edits — through onCellEdit, the same channel inline editing
uses:
<DataTable cellNavigation columns={[{ key: "budget", header: "Budget", editable: true }]} onCellEdit={(row, key, value) => save(row, key, value)}/>That is the whole wiring: a table that can be edited can be pasted into. Each
edit is the same thing an inline commit produces — same parseValue, same
shape, same handler — so paste is not a second editing route, and whatever you
wrap around a single-cell commit already covers a paste of two hundred.
To take the batch whole instead — one server round trip, one undo entry — set
onCellPaste, which takes precedence:
<DataTable cellNavigation onCellPaste={(edits) => saveAll(edits)} onCellEdit={commit}/>The clipboard’s shape wins over the selection’s: pasting a 3×2 block into one
focused cell writes 3×2, as every spreadsheet does. Cells landing outside the
loaded rows or the rendered columns are dropped rather than invented, and a
column that is not editable is skipped — a paste is an edit, and an edit into a
read-only column is not one.
The table writes nothing itself. Applying the edits stays yours, which is what
keeps a paste undoable, validatable and cancellable on your terms. The outcome
is announced through labels.gridRangePasted, or labels.gridRangePasteFailed
when the browser will not hand over the clipboard.
Headless: readClipboardText reads it, parseClipboardTable parses it, and
pasteRangeEdits maps it onto a range.
The fill handle
Section titled “The fill handle”Select a cell or a block and a small square appears on its bottom corner. Drag it and the selection’s values carry on — down, up, or sideways, whichever way the drag mostly goes. The cells it would write are highlighted before anything is committed, so the preview and the result cannot disagree.
Two or more numbers a constant step apart continue the series (1, 2 → 3, 4);
anything else repeats in order (Mon, Tue → Mon, Tue). A single number repeats
rather than counting — one value carries no step, and guessing +1 there is the
behaviour spreadsheets are cursed for.
Ctrl/Cmd+D is the keyboard route: the selection’s top row carries into the
rest of it. The handle itself is not a tab stop, because the grid is one tab
stop and a focusable square inside it would break that; the key press announces
what it wrote through labels.gridRangeFilled.
The edits arrive exactly as a paste’s do — onCellEdit per cell, or
onCellFill for the batch — so the handle appears as soon as a table can be
edited, and never when it cannot:
<DataTable cellNavigation columns={[{ key: "budget", header: "Budget", editable: true }]} onCellEdit={commit}/>The square paints in the cell’s own text colour; --adapttable-fill-handle
changes it. In RTL it sits on the row’s inline end, which is the left, and a
sideways drag follows the same mirroring the arrow keys do.
Headless: fillDirection, fillTargetRange and fillRangeEdits;
FillHandleChrome passes the active corner to an adapter-owned handle.
Find in table
Section titled “Find in table”Set findInTable and Ctrl/Cmd+F opens a find bar over the table:
<DataTable cellNavigation findInTable />Find is not search. The search box asks “show me only the rows that match”, and on a server tier it asks the server; find asks “where does this appear in what I am looking at”, leaves every row where it is, and walks the hits. Both can be on at once.
Every hit is marked — data-cell-match, and data-cell-match-current on the
one you are standing on — painted in the amber every browser paints its own
find hits, because a find highlight is a browser convention rather than a
design-system token. --adapttable-find-match and
--adapttable-find-match-current change it; in @adapttable/unstyled the
cellMatch / cellMatchCurrent class hooks do (the shadcn preset fills them
in).
Enter walks forward, Shift+Enter back, Escape closes and clears.
Walking moves the table’s focus with it, so the cell is scrolled into view,
announced, and left selected — a find that highlighted without going there would
leave you hunting for the highlight. The bar itself is one input, a count and
three buttons, all named through labels.findInTable, findPlaceholder,
findMatchCount, findPrevious, findNext and findClose.
Matching reads what a cell shows, so a column that renders a formatted date is found by that date rather than by the ISO string underneath. Only the loaded rows are searched: a hit the table cannot take you to would be a lie, so on a paged table find covers the page you are on, and under virtualization it covers what has been fetched. The query is deliberately not put in the URL — where you are looking is not part of the table’s state, and a shared link should not reopen someone else’s search box.
On mobile the cards are a list rather than a grid, so the bar opens and searches but the hits are not marked; the desktop layout is where a cell can be pointed at.
Headless: findMatches, matchKeySet, stepMatch, useFindInTable in
@adapttable/core/adapter. Each adapter mounts FindBar over
FindBarChrome.
What the selection adds up to
Section titled “What the selection adds up to”Set selectionStats and a strip under the table says what is selected:
<DataTable cellNavigation selectionStats />Count 12 · Sum 1,240.5 · Avg 103.4 · Min 12 · Max 900
The count covers every selected cell; the arithmetic covers the numeric ones,
so a rectangle spanning a name column and a budget column still has a sum.
Numbers are read the way an export reads them — exportValue included — so the
total here and the total a spreadsheet computes from a paste of the same cells
cannot disagree. Booleans are not counted as numbers: summing a column of ticks
to 3 answers a question nobody asked.
A single cell shows nothing — it has no total worth reading, and a strip that
flickers in on every arrow press is noise. The strip is a status region, so a
screen reader reads the figures after the range announcement rather than
interrupting it, and every word is localizable (labels.selectionCount,
selectionSum, selectionAverage, selectionMin, selectionMax). Number
formatting follows the table’s locale.
Selecting a column covers the LOADED rows, so the figures describe the 500 rows in hand rather than the 100,000 in the dataset — the table never totals rows it has never seen.
Headless: selectionStats(options) returns the figures;
SelectionStatsChrome passes formatted parts to an adapter-owned status bar.
Selecting with the pointer, and whole columns
Section titled “Selecting with the pointer, and whole columns”Drag across cells to select a block: the press anchors it, crossing a cell extends it, and releasing anywhere — including outside the table — ends it.
A column header selects its whole column. Where the header already sorts, sorting keeps the plain click and Ctrl/Cmd+click selects instead; on a header that does not sort, a plain click selects. Ctrl/Cmd+click also extends an existing selection to a second column.
A column selection covers the loaded rows. With 500 of 100,000 rows in hand that is 500 cells, not 100,000 — the table never claims rows the browser has never seen, because a copy or an export would then invent them.
The header checkbox
Section titled “The header checkbox”columnSelectionCheckbox adds a checkbox to every column header that selects
that column. The gesture above is unchanged; this is the same state reached two
ways it cannot be. A touchscreen has no Ctrl key to hold, and a gesture nothing
announces is a gesture nobody finds — so the control is what a finger taps and
what a screen reader reads, named labels.selectColumn plus the column’s own
name (“Select column: Team”, translated in all seventeen locales).
<DataTable cellNavigation columnSelectionCheckbox … />It needs cellNavigation, because that is what makes a selection exist at all;
either prop alone renders nothing. Ticking selects the column, unticking clears
— nothing selected is the only state one checkbox can return to, since a
rectangle cannot lose a column out of its middle. A box reads as checked only
when the selection is exactly its column: inside a wider rectangle it stays
clear rather than saying the selection is one column when it is four.
Where the pointer can hover, the box holds its space and fades in on hover or focus, so a wide header row is not a row of checkboxes and the layout never moves; a selected column keeps its box on screen either way. Where there is no hover — a touchscreen — it is always visible.
The control is data-adapttable-part="column-select" (columnSelect in
classNames), and it is each kit’s own checkbox: core owns the layout, the
name, and keeping the click off the header underneath it, which would otherwise
sort the column the same click just selected.
Whenever the rectangle changes, the live region says what it now covers —
"selected rows 1 to 2, columns 1 to 2, 4 cells" — through
labels.gridRangeSelection, translated in all seventeen locales. A single cell
stays silent: it announces itself already, and repeating “1 cell” on every arrow
press turns navigation into noise.
The selection is visible, in each kit’s own colour
Section titled “The selection is visible, in each kit’s own colour”Hold Shift while arrowing (or shift-click) and the extended range is filled with
the kit’s own selected-cell token — Mantine’s primary-light, MUI’s
action.selected, Ant Design’s active-item background, Radix’s accent, and so
on. Nothing to configure.
Every selected cell also carries data-cell-selected, so CSS can target the
range directly. In @adapttable/unstyled there is no kit colour to borrow, so
the fill is yours through the cellSelected class hook (the shadcn preset sets
bg-accent).
Example
Section titled “Example”<DataTable data={people} columns={columns} rowKey={(row) => row.id} cellNavigation/>That is the whole opt-in. Omit it and nothing changes — see Off means absent.
The keys
Section titled “The keys”| Key | Where focus goes |
|---|---|
↑ ↓ ← → |
One cell, stopping at the edges |
Home / End |
Start / end of the current row |
Ctrl+Home / Ctrl+End |
First / last cell of the whole grid |
PageUp / PageDown |
A viewport’s worth of rows |
Enter / F2 |
Opens the editor, when the column is editable |
Ctrl/Cmd+C / X / V |
Copy, cut, paste the selected rectangle |
Ctrl/Cmd+D |
Fill the selection down from its top row |
Tab |
Leaves the table — it is one stop, not hundreds |
Edges stop rather than wrap. Wrapping off the last column would move the user to a different record without saying so; a table is not a spreadsheet.
Under dir="rtl" the left and right arrows swap, because arrow keys describe
the screen rather than the data — in a mirrored table the visually-next column
is the previous one. Home and End do not swap: they mean the start and end
of the row either way.
What a screen reader hears
Section titled “What a screen reader hears”Focus alone announces a cell’s contents, which is not navigation — “1,240” says nothing about which column it belongs to or where it sits in a dataset whose end you cannot see. So a live region announces the column, the cell’s text, and the absolute position:
Budget, 1,240, row 40,002 of 100,000
The cell’s text comes from columnText, so a column whose cell
renders a badge or an avatar needs a formatValue to have anything readable.
The position phrase is localizable through labels.gridCellPosition, and ships
translated in all seventeen locales.
Virtualization
Section titled “Virtualization”This is where a naive implementation is wrong and looks right. With 24 rows of
100,000 rendered, aria-rowindex must number rows within the dataset, not
within the window — otherwise assistive technology reports “row 3 of 24” while
the user is at row 40,000. AdaptTable carries absolute aria-rowindex /
aria-colindex and dataset-wide aria-rowcount / aria-colcount.
Ctrl+End on a 100,000-row table also asks for a cell that is not in the DOM
at all. Focus scrolls it into existence and lands on it once it mounts.
Selecting a range
Section titled “Selecting a range”Hold Shift with any movement key, or shift-click a cell, and the selection extends from where it began:
| Gesture | Result |
|---|---|
Shift+↑ ↓ ← → |
Extend the rectangle one cell |
Shift+Home / End |
Extend to the row’s start / end |
Shift+PageUp / PageDown |
Extend by a viewport of rows |
Shift+click |
Extend to the clicked cell |
| Any plain move | Collapse back to a single cell |
A range is stored as two corners — the anchor where it started and the head where it reaches — not as a list of cells. That is what makes Shift+Down twice then Shift+Up shrink the range instead of starting a new one upward, and what makes a 50,000-cell selection cost two numbers.
Selected cells carry data-cell-selected for styling. aria-selected appears
only once a real rectangle exists: marking every focused cell as selected would
tell a screen reader the table is in selection mode when the user has merely
arrowed around.
onRangeChange fires whenever it changes and table.gridFocus.range holds the
current rectangle — which is what exportCsv with scope: "range" reads.
Headless: CellRange and CellRangeBounds are the shapes, cellRangeBounds
sorts the corners of a range dragged up or left, isInCellRange tests
membership, cellRangeSize multiplies rather than enumerating,
extendCellRange moves the head while keeping the anchor, singleCellRange
and isSingleCell handle the one-cell case, and cellRangeIndices enumerates
the rows and columns for an exporter — the one place that does.
Mobile
Section titled “Mobile”Cell navigation applies to the desktop table layout. Mobile cards are a list, not a grid: they keep list semantics, and arrow keys are not hijacked there. This is deliberate — a card is one record’s worth of stacked label/value pairs, and a two-dimensional focus model does not describe it.
Off means absent
Section titled “Off means absent”With cellNavigation omitted there is no role="grid", no tabIndex, no key
handler, no live region, and no extra attributes. Not “disabled” — absent. A
test asserts the rendered markup is byte-identical to a table built without the
prop at all, in every one of the eight adapters.
Focus position is also deliberately not saved to the URL or a Saved View. Where the keyboard is sitting is ephemeral UI state, not part of a view someone would share.
Headless
Section titled “Headless”| Export | Purpose |
|---|---|
useGridFocus / UseGridFocusOptions / GridFocusState |
The hook, its options, and the state it returns (getGridProps, getCellPropsAt, getRowPropsAt, focusCell, announcement). |
GridFocusAnnouncer / GridFocusAnnouncerProps |
The live region, from @adapttable/core/adapter. Renders nothing when navigation is off. |
moveGridFocus / GridFocusMove / GridBounds |
The pure move arithmetic and its vocabulary — no React, no DOM. |
gridFocusMoveForKey / GridKeyPress |
Maps a key press to a move, applying the RTL swap. |
GridCell / sameGridCell |
A cell address, and address equality. |
GRID_CELL_ATTR / gridCellAttr |
The data-grid-cell attribute focus uses to find a cell in the DOM. |
getCellPropsAt(windowIndex, col) and getRowPropsAt(windowIndex) take the
index an adapter already has — its position in the rendered rows — and convert
to the absolute address internally. That conversion lives in core precisely
because getting it wrong is invisible on screen.
- Works in all eight adapters, verified by the same parity test in each.
- Enter and F2 are handled by the editing gate on the focused cell, so the two keyboard models never race for one key press.
- A click moves focus too: state follows the DOM rather than fighting it.