Complex Tables Made Accessible: Building Navigable Data Grids in React
An architectural guide to building production-ready, highly accessible data grids in React and TypeScript using ARIA grid roles, roving tabindex patterns, and robust keyboard navigation.
Complex Tables Made Accessible: Building Navigable Data Grids in React
Tables are among the most difficult user interface components to make truly accessible. While standard HTML tables (<table>, <thead>, <tbody>, <tr>, <th>, <td>) work well for static, document-style data, modern web applications demand interactive data grids. These grids feature sorting, filtering, selection, and cell-level editing.
When you transform a static table into a dynamic spreadsheet-like interface, standard screen reader table navigation falls short. Screen readers rely on table headers to announce coordinates (e.g., “Row 3, Column 2, Age, 28”). However, if your cells contain interactive elements like buttons, inputs, or menus, native document navigation breaks down.
This guide explores how to build a production-ready, fully accessible data grid in React and TypeScript. We will implement ARIA grid roles, the roving tabindex pattern for keyboard navigation, sort states, and screen reader announcements.
1. Architectural Foundation: Native Tables vs. ARIA Grids
Before writing code, we must decide between a semantic HTML table approach and the WAI-ARIA Grid pattern.
- Standard HTML Tables (
role="table"): Best for read-only tabular data. They allow screen reader users to jump from cell to cell using standard table navigation commands. - ARIA Grids (
role="grid"): Best for interactive data tables where cells contain focusable widgets or where users need spreadsheet-like cell navigation. In an ARIA grid, the component acts as a single composite widget.
We will implement the ARIA Grid pattern. This requires:
- The container element has
role="grid". - Rows have
role="row". - Headers have
role="columnheader"(witharia-sortwhen applicable). - Cells have
role="gridcell". - Only one element in the entire grid is focusable at any given time (
tabIndex={0}), while all other focusable cells havetabIndex={-1}. This is the roving tabindex pattern.
2. Defining Types and State Interfaces
Let us start by defining our TypeScript interfaces for the data grid configuration, sort state, and coordinate system.
import React, { useState, useRef, useEffect, useCallback, KeyboardEvent } from 'react';
export type SortDirection = 'ascending' | 'descending' | 'none';
export interface Column<T> {
id: keyof T | string;
header: string;
accessor: (row: T) => React.ReactNode;
sortable?: boolean;
width?: string;
}
export interface GridProps<T> {
data: T[];
columns: Column<T>[];
caption: string;
ariaLabel?: string;
onRowClick?: (row: T, index: number) => void;
}
export interface CellCoordinates {
rowIndex: number;
colIndex: number;
}
3. Implementing the Roving Tabindex Hook
The heart of grid accessibility is keyboard navigation. Users expect to navigate a grid using the Arrow keys, Home, End, Page Up, and Page Down.
Let’s isolate our navigation logic into a custom hook that manages active cell coordinates and handles keyboard events.
interface UseGridNavigationProps {
rowCount: number;
colCount: number;
onActivateCell?: (coords: CellCoordinates) => void;
}
export function useGridNavigation({ rowCount, colCount, onActivateCell }: UseGridNavigationProps) {
const [activeCell, setActiveCell] = useState<CellCoordinates>({ rowIndex: 0, colIndex: 0 });
const handleKeyDown = (e: KeyboardEvent<HTMLElement>) => {
let { rowIndex, colIndex } = activeCell;
let handled = true;
switch (e.key) {
case 'ArrowDown':
rowIndex = Math.min(rowIndex + 1, rowCount - 1);
break;
case 'ArrowUp':
rowIndex = Math.max(rowIndex - 1, 0);
break;
case 'ArrowRight':
colIndex = Math.min(colIndex + 1, colCount - 1);
break;
case 'ArrowLeft':
colIndex = Math.max(colIndex - 1, 0);
break;
case 'Home':
if (e.ctrlKey) {
rowIndex = 0;
colIndex = 0;
} else {
colIndex = 0;
}
break;
case 'End':
if (e.ctrlKey) {
rowIndex = rowCount - 1;
colIndex = colCount - 1;
} else {
colIndex = colCount - 1;
}
break;
case 'Enter':
case ' ':
if (onActivateCell) {
onActivateCell({ rowIndex, colIndex });
}
handled = false;
break;
default:
handled = false;
break;
}
if (handled) {
e.preventDefault();
setActiveCell({ rowIndex, colIndex });
}
};
return {
activeCell,
setActiveCell,
handleKeyDown,
};
}
4. Building the Accessible DataGrid Component
Now we assemble the complete component, integrating sorting logic, ARIA attributes, and our roving tabindex hook.
export function AccessibleDataGrid<T extends Record<string, any>>({
data,
columns,
caption,
ariaLabel,
onRowClick,
}: GridProps<T>) {
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>('none');
const [announcement, setAnnouncement] = useState<string>('');
// Sorting logic
const sortedData = React.useMemo(() => {
if (!sortColumn || sortDirection === 'none') return data;
return [...data].sort((a, b) => {
const aValue = a[sortColumn];
const bValue = b[sortColumn];
if (aValue < bValue) return sortDirection === 'ascending' ? -1 : 1;
if (aValue > bValue) return sortDirection === 'ascending' ? 1 : -1;
return 0;
});
}, [data, sortColumn, sortDirection]);
const { activeCell, setActiveCell, handleKeyDown } = useGridNavigation({
rowCount: sortedData.length,
colCount: columns.length,
onActivateCell: ({ rowIndex, colIndex }) => {
const row = sortedData[rowIndex];
const col = columns[colIndex];
if (onRowClick) onRowClick(row, rowIndex);
setAnnouncement(`Activated row ${rowIndex + 1}, column ${col.header}`);
},
});
// Reference map to programmatically focus the active cell DOM node
const cellRefs = useRef<(HTMLTableCellElement | HTMLDivElement)[][]>([]);
useEffect(() => {
const currentCell = cellRefs.current[activeCell.rowIndex]?.[activeCell.colIndex];
if (currentCell) {
currentCell.focus();
}
}, [activeCell]);
const handleSort = (columnId: string, headerName: string) => {
let newDirection: SortDirection = 'ascending';
if (sortColumn === columnId) {
if (sortDirection === 'ascending') newDirection = 'descending';
else if (sortDirection === 'descending') newDirection = 'none';
}
setSortColumn(newDirection === 'none' ? null : columnId);
setSortDirection(newDirection);
setAnnouncement(`Table sorted by ${headerName}, ${newDirection}`);
};
return (
<div className="grid-container">
{/* Live region for announcing state changes to screen readers */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{announcement}
</div>
<table
role="grid"
aria-label={ariaLabel}
aria-rowcount={sortedData.length + 1}
aria-colcount={columns.length}
onKeyDown={handleKeyDown}
className="accessible-grid"
>
<caption>{caption}</caption>
<thead>
<tr role="row" aria-rowindex={1}>
{columns.map((col, colIndex) => {
const isSorted = sortColumn === col.id;
const currentSortDir = isSorted ? sortDirection : 'none';
return (
<th
key={String(col.id)}
role="columnheader"
aria-colindex={colIndex + 1}
aria-sort={col.sortable ? currentSortDir : undefined}
scope="col"
style={{ width: col.width }}
>
{col.sortable ? (
<button
type="button"
className="sort-button"
onClick={() => handleSort(String(col.id), col.header)}
>
{col.header}
<span className="sort-indicator" aria-hidden="true">
{isSorted && sortDirection === 'ascending' ? ' ▲' : ''}
{isSorted && sortDirection === 'descending' ? ' ▼' : ''}
</span>
</button>
) : (
col.header
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{sortedData.map((row, rowIndex) => {
if (!cellRefs.current[rowIndex]) {
cellRefs.current[rowIndex] = [];
}
return (
<tr key={rowIndex} role="row" aria-rowindex={rowIndex + 2}>
{columns.map((col, colIndex) => {
const isActive =
activeCell.rowIndex === rowIndex && activeCell.colIndex === colIndex;
return (
<td
key={String(col.id)}
role="gridcell"
aria-colindex={colIndex + 1}
tabIndex={isActive ? 0 : -1}
ref={(el) => {
if (el) cellRefs.current[rowIndex][colIndex] = el;
}}
onFocus={() => setActiveCell({ rowIndex, colIndex })}
className={`grid-cell ${isActive ? 'active-cell' : ''}`}
>
{col.accessor(row)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
5. Styling for Focus Management and Visual Feedback
Accessibility requires clear visual indicators. Users relying on keyboards must instantly see which cell currently holds focus.
Add the following CSS to your project to handle focus states and hide screen-reader-only announcement containers visually:
.grid-container {
overflow-x: auto;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
.accessible-grid {
width: 100%;
border-collapse: collapse;
text-align: left;
font-family: inherit;
}
.accessible-grid th,
.accessible-grid td {
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
}
.accessible-grid th {
background-color: #f8fafc;
font-weight: 600;
}
/* Critical: Clear focus indicator for roving tabindex */
.grid-cell:focus {
outline: 2px solid #3b82f6;
outline-offset: -2px;
background-color: #eff6ff;
}
.sort-button {
background: none;
border: none;
font: inherit;
font-weight: 600;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
gap: 4px;
}
/* Screen Reader Only Utility */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
6. Testing Your Accessible Data Grid
Building accessibility features into code is only half the battle. You must verify them across multiple assistive technologies.
Keyboard Testing Checklist
- Press
Tabto navigate into the grid. Focus should land on the first cell (rowIndex: 0, colIndex: 0) or the first header button. - Use
Arrowkeys (Up,Down,Left,Right) to move across cells freely. - Press
Hometo jump to the start of the current row, andCtrl + Hometo jump to the top-left cell of the entire grid. - Press
EnterorSpaceon an interactive cell to trigger row activation handlers.
Screen Reader Verification
- VoiceOver (macOS) / NVDA (Windows): Ensure that entering the table announces the grid summary/caption and header associations accurately.
- Live Region Announcer: Verify that sorting actions trigger polite announcements (e.g., “Table sorted by Age, ascending”) without interrupting active screen reader speech.
Conclusion
Creating accessible data grids in React requires moving beyond standard semantic HTML tags into the realm of composite WAI-ARIA widgets. By pairing a robust roving tabindex pattern with proper ARIA roles, aria-sort states, and aria-live regions, you can build complex, spreadsheet-like interfaces that remain entirely navigable for keyboard and screen reader users alike.