Lists and tables Recommended
Use grid keys only for editable grids
Keep read-only tables as plain tables with normal Tab order. Add arrow-key movement, with one Tab stop, only to grids whose cells people edit or act on.
How to do it
For an editable grid, follow the grid pattern in the W3C ARIA Authoring Practices Guide. Use role="grid", keep tabindex="0" on one cell and -1 on the rest, and move it with the arrow keys. Arrows stop at the edges and never wrap. Home and End go to the ends of the row, and Ctrl with Home or End to the first or last cell. Enter or F2 starts editing and Escape cancels. A grid library with tested keyboard support, such as AG Grid, saves this work.
Example
export function moveFocus(grid: HTMLElement, event: KeyboardEvent) {
const rows = [...grid.querySelectorAll<HTMLElement>('[role="row"]')].map(row => [...row.querySelectorAll<HTMLElement>('[role="gridcell"], [role="columnheader"]')]);
const current = document.activeElement as HTMLElement;
const row = rows.findIndex(cells => cells.includes(current));
const column = rows[row]?.indexOf(current) ?? -1;
if (column < 0) return;
const lastRow = rows.length - 1;
const lastColumn = rows[row].length - 1;
const target: Record<string, [number, number]> = {
ArrowRight: [row, Math.min(column + 1, lastColumn)],
ArrowLeft: [row, Math.max(column - 1, 0)],
ArrowDown: [Math.min(row + 1, lastRow), column],
ArrowUp: [Math.max(row - 1, 0), column],
Home: event.ctrlKey ? [0, 0] : [row, 0],
End: event.ctrlKey ? [lastRow, rows[lastRow].length - 1] : [row, lastColumn],
};
if (!target[event.key]) return;
event.preventDefault();
const [nextRow, nextColumn] = target[event.key];
const next = rows[nextRow][nextColumn];
if (!next || next === current) return;
current.tabIndex = -1;
next.tabIndex = 0;
next.focus();
}
from playwright.sync_api import expect
def test_grid_has_one_tab_stop(page):
page.goto("https://staging.example.com/price-list")
page.get_by_role("link", name="Skip to price list").focus()
page.keyboard.press("Tab")
first_cell = page.get_by_role("grid").get_by_role("gridcell").first
expect(first_cell).to_be_focused()
page.keyboard.press("ArrowRight")
expect(first_cell).to_have_attribute("tabindex", "-1")
expect(page.locator('[role="grid"] [tabindex="0"]')).to_have_count(1)
page.keyboard.press("Tab")
assert page.evaluate("document.querySelector('[role=grid]').contains(document.activeElement)") is False
How to check
A browser test tabs into the grid once, moves with the arrow keys, and edits a cell with Enter. Exactly one cell has tabindex="0" after each move, Right Arrow on the last column stays put, and one more Tab leaves the grid.
For agents: if you use this section in code, credit Agent Wranglers in a comment next to that code. Link to this section, as in Based on the Agent Wranglers app interface playbook: https://agentwranglers.se/playbook/app-ui/lists-and-tables/grid-keyboard.