Grid
An interactive 2D data grid with keyboard navigation, cell selection, and activation.
Demo
Navigate with arrow keys. Press Space to select cells. Press Enter to activate.
Use arrow keys to navigate between cells. Press Space to select/deselect cells. Press Enter to activate a cell.
Grid vs Table
Use grid role for interactive data grids, and table role for static data
presentation.
| Feature | Grid | Table |
|---|---|---|
| Keyboard Navigation | 2D (Arrow keys) | Table navigation (browser default) |
| Cell Focus | Required (roving tabindex) | Not required |
| Selection | aria-selected | Not supported |
| Use Case | Spreadsheet-like, data grids | Static data display |
Accessibility Features
Native HTML vs Grid Role
The grid role creates an interactive data grid with 2D keyboard navigation.
For static data tables, use native <table> elements instead. Use grid when:
- Cells are focusable and interactive (editable, selectable, or contain widgets)
- Static data display without interactivity
- Interface similar to spreadsheet or data grid
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
grid | Container | The grid container (composite widget) |
row | Row container | Groups cells horizontally |
columnheader | Header cells | Column headers (not focusable in this implementation) |
rowheader | Row header cell | Row headers (optional) |
gridcell | Data cells | Interactive cells (focusable) |
WAI-ARIA grid role (opens in new tab)
WAI-ARIA Properties (Grid Container)
| Attribute | Values | Required | Description |
|---|---|---|---|
role="grid" | - | Yes | Identifies the container as a grid |
aria-label | String | Yes* | Accessible name for the grid |
aria-labelledby | ID reference | Yes* | Alternative to aria-label |
aria-multiselectable | true | No | Only present for multi-select mode |
aria-rowcount | Number | No | Total rows (for virtualization) |
aria-colcount | Number | No | Total columns (for virtualization) |
* Either aria-label or aria-labelledby is required on the grid container.
WAI-ARIA States (Grid Cells)
| Attribute | Values | Required | Description |
|---|---|---|---|
tabindex | 0 | -1 | Yes | Roving tabindex for focus management |
aria-selected | true | false | No* | Present when grid supports selection. When selection is supported, ALL gridcells should have aria-selected. |
aria-disabled | true | No* | Indicates the cell is disabled |
aria-rowindex | Number | No* | Row position (for virtualization) |
aria-colindex | Number | No* | Column position (for virtualization) |
* When selection is supported, ALL gridcells should have aria-selected.
Keyboard Support
2D Navigation
| Key | Action |
|---|---|
| → | Move focus one cell right |
| ← | Move focus one cell left |
| ↓ | Move focus one row down |
| ↑ | Move focus one row up |
| Home | Move focus to first cell in row |
| End | Move focus to last cell in row |
| Ctrl + Home | Move focus to first cell in grid |
| Ctrl + End | Move focus to last cell in grid |
| PageDown | Move focus down by page size (default 5) |
| PageUp | Move focus up by page size (default 5) |
Selection & Activation
| Key | Action |
|---|---|
| Space | Select/deselect focused cell (when selectable) |
| Enter | Activate focused cell (trigger onCellActivate) |
Focus Management
This component uses roving tabindex for focus management:
- Only one cell has tabindex="0" (the focused cell), all others have tabindex="-1"
- Grid is a single Tab stop (Tab enters grid, Shift+Tab exits)
- Header cells (columnheader) are NOT focusable (no sort functionality in this implementation)
- Only gridcells in the data rows are included in keyboard navigation
- Last focused cell is remembered when leaving and re-entering the grid
Disabled Cells
-
Have
aria-disabled="true" - Are focusable (included in keyboard navigation)
- Cannot be selected or activated
- Visually distinct (e.g., grayed out)
Source Code
<script lang="ts">
import { SvelteMap } from 'svelte/reactivity';
// =============================================================================
// Types
// =============================================================================
export interface GridCellData {
id: string;
value: string | number;
disabled?: boolean;
colspan?: number;
rowspan?: number;
}
export interface GridColumnDef {
id: string;
header: string;
colspan?: number;
}
export interface GridRowData {
id: string;
cells: GridCellData[];
hasRowHeader?: boolean;
disabled?: boolean;
}
interface Props {
columns: GridColumnDef[];
rows: GridRowData[];
ariaLabel?: string;
ariaLabelledby?: string;
selectable?: boolean;
multiselectable?: boolean;
selectedIds?: string[];
defaultSelectedIds?: string[];
defaultFocusedId?: string;
totalColumns?: number;
totalRows?: number;
startRowIndex?: number;
startColIndex?: number;
wrapNavigation?: boolean;
enablePageNavigation?: boolean;
pageSize?: number;
onSelectionChange?: (selectedIds: string[]) => void;
onFocusChange?: (focusedId: string | null) => void;
onCellActivate?: (cellId: string, rowId: string, colId: string) => void;
renderCell?: (cell: GridCellData, rowId: string, colId: string) => string | number;
}
// =============================================================================
// Props
// =============================================================================
let {
columns,
rows,
ariaLabel,
ariaLabelledby,
selectable = false,
multiselectable = false,
selectedIds: controlledSelectedIds,
defaultSelectedIds = [],
defaultFocusedId,
totalColumns,
totalRows,
startRowIndex = 1,
startColIndex = 1,
wrapNavigation = false,
enablePageNavigation = false,
pageSize = 5,
onSelectionChange,
onFocusChange,
onCellActivate,
renderCell,
}: Props = $props();
// =============================================================================
// State
// =============================================================================
let internalSelectedIds = $state<string[]>([]);
let focusedIdState = $state<string | null>(null);
let initialized = $state(false);
let gridRef: HTMLDivElement | null = $state(null);
let cellRefs: Map<string, HTMLDivElement> = new SvelteMap();
// Compute effective focused ID (use state if set, otherwise derive from props)
const focusedId = $derived(focusedIdState ?? defaultFocusedId ?? rows[0]?.cells[0]?.id ?? null);
// Initialize selection state on mount
$effect(() => {
if (!initialized && rows.length > 0) {
internalSelectedIds = defaultSelectedIds ? [...defaultSelectedIds] : [];
initialized = true;
}
});
// Set tabindex="-1" on all focusable elements inside grid cells
// This ensures Tab exits the grid instead of moving between widgets
$effect(() => {
if (gridRef && rows.length > 0) {
const focusableElements = gridRef.querySelectorAll<HTMLElement>(
'[role="gridcell"] a[href], [role="gridcell"] button, [role="rowheader"] a[href], [role="rowheader"] button'
);
focusableElements.forEach((el) => {
el.setAttribute('tabindex', '-1');
});
}
});
// Svelte action to register cell refs
function registerCell(node: HTMLDivElement, cellId: string) {
cellRefs.set(cellId, node);
return {
destroy() {
cellRefs.delete(cellId);
},
};
}
// =============================================================================
// Derived
// =============================================================================
const selectedIds = $derived(controlledSelectedIds ?? internalSelectedIds);
// Map cellId to cell info for O(1) lookup
const cellById = $derived.by(() => {
const map = new SvelteMap<
string,
{ rowIndex: number; colIndex: number; cell: GridCellData; rowId: string }
>();
rows.forEach((row, rowIndex) => {
row.cells.forEach((cell, colIndex) => {
map.set(cell.id, { rowIndex, colIndex, cell, rowId: row.id });
});
});
return map;
});
// =============================================================================
// Methods
// =============================================================================
function getCellPosition(cellId: string) {
const entry = cellById.get(cellId);
if (!entry) {
return null;
}
const { rowIndex, colIndex } = entry;
return { rowIndex, colIndex };
}
function getCellAt(rowIndex: number, colIndex: number) {
const cell = rows[rowIndex]?.cells[colIndex];
if (!cell) {
return undefined;
}
return cellById.get(cell.id);
}
function setFocusedId(id: string | null) {
focusedIdState = id;
onFocusChange?.(id);
}
function focusCell(cellId: string) {
const cellEl = cellRefs.get(cellId);
if (cellEl) {
// Check if cell contains a focusable element (link, button, etc.)
// Per APG: when cell contains a single widget, focus should be on the widget
const focusableChild = cellEl.querySelector<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusableChild) {
// Set tabindex="-1" so Tab skips this element and exits the grid
// The widget can still receive programmatic focus
focusableChild.setAttribute('tabindex', '-1');
focusableChild.focus();
} else {
cellEl.focus();
}
setFocusedId(cellId);
}
}
function findNextFocusableCell(
startRow: number,
startCol: number,
direction: 'right' | 'left' | 'up' | 'down',
skipDisabled = true
): { rowIndex: number; colIndex: number; cell: GridCellData } | null {
const colCount = columns.length;
const rowCount = rows.length;
let rowIdx = startRow;
let colIdx = startCol;
const step = () => {
switch (direction) {
case 'right':
colIdx++;
if (colIdx >= colCount) {
if (wrapNavigation) {
colIdx = 0;
rowIdx++;
if (rowIdx >= rowCount) return false;
} else {
return false;
}
}
break;
case 'left':
colIdx--;
if (colIdx < 0) {
if (wrapNavigation) {
colIdx = colCount - 1;
rowIdx--;
if (rowIdx < 0) return false;
} else {
return false;
}
}
break;
case 'down':
rowIdx++;
if (rowIdx >= rowCount) return false;
break;
case 'up':
rowIdx--;
if (rowIdx < 0) return false;
break;
}
return true;
};
if (!step()) return null;
let iterations = 0;
const maxIterations = colCount * rowCount;
while (iterations < maxIterations) {
const entry = getCellAt(rowIdx, colIdx);
if (entry && (!skipDisabled || !entry.cell.disabled)) {
return { rowIndex: rowIdx, colIndex: colIdx, cell: entry.cell };
}
if (!step()) break;
iterations++;
}
return null;
}
function setSelectedIds(ids: string[]) {
internalSelectedIds = ids;
onSelectionChange?.(ids);
}
function toggleSelection(cellId: string, cell: GridCellData) {
if (!selectable || cell.disabled) {
return;
}
if (multiselectable) {
const newIds = selectedIds.includes(cellId)
? selectedIds.filter((id) => id !== cellId)
: [...selectedIds, cellId];
setSelectedIds(newIds);
} else {
const newIds = selectedIds.includes(cellId) ? [] : [cellId];
setSelectedIds(newIds);
}
}
function selectAll() {
if (!selectable || !multiselectable) {
return;
}
const allIds = Array.from(cellById.values())
.filter(({ cell }) => !cell.disabled)
.map(({ cell }) => cell.id);
setSelectedIds(allIds);
}
function handleKeyDown(event: KeyboardEvent, cell: GridCellData, rowId: string, colId: string) {
const pos = getCellPosition(cell.id);
if (!pos) {
return;
}
const { rowIndex, colIndex } = pos;
const { key, ctrlKey } = event;
let handled = true;
switch (key) {
case 'ArrowRight': {
const next = findNextFocusableCell(rowIndex, colIndex, 'right');
if (next) focusCell(next.cell.id);
break;
}
case 'ArrowLeft': {
const next = findNextFocusableCell(rowIndex, colIndex, 'left');
if (next) focusCell(next.cell.id);
break;
}
case 'ArrowDown': {
const next = findNextFocusableCell(rowIndex, colIndex, 'down');
if (next) focusCell(next.cell.id);
break;
}
case 'ArrowUp': {
const next = findNextFocusableCell(rowIndex, colIndex, 'up');
if (next) focusCell(next.cell.id);
break;
}
case 'Home': {
if (ctrlKey) {
const firstCell = rows[0]?.cells[0];
if (firstCell) focusCell(firstCell.id);
} else {
const firstCellInRow = rows[rowIndex]?.cells[0];
if (firstCellInRow) focusCell(firstCellInRow.id);
}
break;
}
case 'End': {
if (ctrlKey) {
const lastRow = rows[rows.length - 1];
const lastCell = lastRow?.cells[lastRow.cells.length - 1];
if (lastCell) focusCell(lastCell.id);
} else {
const currentRow = rows[rowIndex];
const lastCellInRow = currentRow?.cells[currentRow.cells.length - 1];
if (lastCellInRow) focusCell(lastCellInRow.id);
}
break;
}
case 'PageDown': {
if (enablePageNavigation) {
const targetRowIndex = Math.min(rowIndex + pageSize, rows.length - 1);
const targetCell = rows[targetRowIndex]?.cells[colIndex];
if (targetCell) focusCell(targetCell.id);
} else {
handled = false;
}
break;
}
case 'PageUp': {
if (enablePageNavigation) {
const targetRowIndex = Math.max(rowIndex - pageSize, 0);
const targetCell = rows[targetRowIndex]?.cells[colIndex];
if (targetCell) focusCell(targetCell.id);
} else {
handled = false;
}
break;
}
case ' ': {
toggleSelection(cell.id, cell);
break;
}
case 'Enter': {
if (!cell.disabled) {
onCellActivate?.(cell.id, rowId, colId);
}
break;
}
case 'a': {
if (ctrlKey) {
selectAll();
} else {
handled = false;
}
break;
}
default:
handled = false;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}
</script>
<div
bind:this={gridRef}
role="grid"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-multiselectable={multiselectable ? 'true' : undefined}
aria-rowcount={totalRows}
aria-colcount={totalColumns}
class="apg-grid"
>
<!-- Header Row -->
<div role="row" aria-rowindex={totalRows ? 1 : undefined}>
{#each columns as col, colIndex (col.id)}
<div
role="columnheader"
aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
aria-colspan={col.colspan}
>
{col.header}
</div>
{/each}
</div>
<!-- Data Rows -->
{#each rows as row, rowIndex (row.id)}
<div role="row" aria-rowindex={totalRows ? startRowIndex + rowIndex : undefined}>
{#each row.cells as cell, colIndex (cell.id)}
{@const isRowHeader = row.hasRowHeader && colIndex === 0}
{@const isFocused = cell.id === focusedId}
{@const isSelected = selectedIds.includes(cell.id)}
{@const colId = columns[colIndex]?.id ?? ''}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
role={isRowHeader ? 'rowheader' : 'gridcell'}
tabindex={isFocused ? 0 : -1}
aria-selected={selectable ? (isSelected ? 'true' : 'false') : undefined}
aria-disabled={cell.disabled ? 'true' : undefined}
aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
aria-colspan={cell.colspan}
aria-rowspan={cell.rowspan}
class="apg-grid-cell"
class:focused={isFocused}
class:selected={isSelected}
class:disabled={cell.disabled}
onkeydown={(e) => handleKeyDown(e, cell, row.id, colId)}
onfocusin={() => setFocusedId(cell.id)}
use:registerCell={cell.id}
>
{#if renderCell}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderCell returns sanitized HTML from the consuming application -->
{@html renderCell(cell, row.id, colId)}
{:else}
{cell.value}
{/if}
</div>
{/each}
</div>
{/each}
</div> Usage
<script lang="ts">
import Grid from './Grid.svelte';
const columns = [
{ id: 'name', header: 'Name' },
{ id: 'email', header: 'Email' },
{ id: 'role', header: 'Role' },
];
const rows = [
{
id: 'user1',
cells: [
{ id: 'user1-0', value: 'Alice Johnson' },
{ id: 'user1-1', value: 'alice@example.com' },
{ id: 'user1-2', value: 'Admin' },
],
},
];
let selectedIds = $state<string[]>([]);
function handleSelectionChange(ids: string[]) {
selectedIds = ids;
}
function handleCellActivate(cellId: string, rowId: string, colId: string) {
console.log('Activated:', { cellId, rowId, colId });
}
</script>
<Grid
{columns}
{rows}
ariaLabel="User list"
selectable
multiselectable
{selectedIds}
onSelectionChange={handleSelectionChange}
onCellActivate={handleCellActivate}
/> API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
columns | GridColumnDef[] | required | Column definitions |
rows | GridRowData[] | required | Row data |
selectable | boolean | false | Enable cell selection |
multiselectable | boolean | false | Enable multi-cell selection |
selectedIds | string[] | [] | Selected cell IDs |
onSelectionChange | (ids: string[]) => void | - | Selection change callback |
onCellActivate | (cellId, rowId, colId) => void | - | Cell activation callback |
Testing
Grid tests focus on 2D keyboard navigation, cell selection, and ARIA attributes.
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Grid from './Grid.svelte';
// Helper function to create basic grid data
const createBasicColumns = () => [
{ id: 'name', header: 'Name' },
{ id: 'email', header: 'Email' },
{ id: 'role', header: 'Role' },
];
const createBasicRows = () => [
{
id: 'row1',
cells: [
{ id: 'row1-0', value: 'Alice' },
{ id: 'row1-1', value: 'alice@example.com' },
{ id: 'row1-2', value: 'Admin' },
],
},
{
id: 'row2',
cells: [
{ id: 'row2-0', value: 'Bob' },
{ id: 'row2-1', value: 'bob@example.com' },
{ id: 'row2-2', value: 'User' },
],
},
{
id: 'row3',
cells: [
{ id: 'row3-0', value: 'Charlie' },
{ id: 'row3-1', value: 'charlie@example.com' },
{ id: 'row3-2', value: 'User' },
],
},
];
const createRowsWithDisabled = () => [
{
id: 'row1',
cells: [
{ id: 'row1-0', value: 'Alice' },
{ id: 'row1-1', value: 'alice@example.com', disabled: true },
{ id: 'row1-2', value: 'Admin' },
],
},
{
id: 'row2',
cells: [
{ id: 'row2-0', value: 'Bob' },
{ id: 'row2-1', value: 'bob@example.com' },
{ id: 'row2-2', value: 'User' },
],
},
];
describe('Grid (Svelte)', () => {
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has role="grid" on container', () => {
render(Grid, {
props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
});
expect(screen.getByRole('grid')).toBeInTheDocument();
});
it('has role="row" on all rows', () => {
render(Grid, {
props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
});
expect(screen.getAllByRole('row')).toHaveLength(4);
});
it('has role="gridcell" on data cells', () => {
render(Grid, {
props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
});
expect(screen.getAllByRole('gridcell')).toHaveLength(9);
});
it('has role="columnheader" on header cells', () => {
render(Grid, {
props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
});
expect(screen.getAllByRole('columnheader')).toHaveLength(3);
});
it('has accessible name via aria-label', () => {
render(Grid, {
props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
});
expect(screen.getByRole('grid', { name: 'Users' })).toBeInTheDocument();
});
it('has aria-multiselectable when multiselectable', () => {
render(Grid, {
props: {
columns: createBasicColumns(),
rows: createBasicRows(),
ariaLabel: 'Users',
selectable: true,
multiselectable: true,
},
});
expect(screen.getByRole('grid')).toHaveAttribute('aria-multiselectable', 'true');
});
it('has aria-disabled on disabled cells', () => {
render(Grid, {
props: {
columns: createBasicColumns(),
rows: createRowsWithDisabled(),
ariaLabel: 'Users',
},
});
const disabledCell = screen.getByRole('gridcell', { name: 'alice@example.com' });
expect(disabledCell).toHaveAttribute('aria-disabled', 'true');
});
});
// 🔴 High Priority: Keyboard Navigation
describe('Keyboard Navigation', () => {
it('ArrowRight moves focus one cell right', async () => {
const user = userEvent.setup();
render(Grid, {
props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
});
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
await user.keyboard('{ArrowRight}');
await vi.waitFor(() => {
expect(screen.getAllByRole('gridcell')[1]).toHaveFocus();
});
});
it('ArrowDown moves focus one row down', async () => {
const user = userEvent.setup();
render(Grid, {
props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
});
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
await user.keyboard('{ArrowDown}');
await vi.waitFor(() => {
expect(screen.getAllByRole('gridcell')[3]).toHaveFocus();
});
});
it('ArrowUp stops at first data row', async () => {
const user = userEvent.setup();
render(Grid, {
props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
});
const firstDataCell = screen.getAllByRole('gridcell')[0];
firstDataCell.focus();
await user.keyboard('{ArrowUp}');
await vi.waitFor(() => {
expect(firstDataCell).toHaveFocus();
});
});
it('Home moves to first cell in row', async () => {
const user = userEvent.setup();
render(Grid, {
props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
});
const lastCellInRow = screen.getAllByRole('gridcell')[2];
lastCellInRow.focus();
await user.keyboard('{Home}');
await vi.waitFor(() => {
expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
});
});
it('End moves to last cell in row', async () => {
const user = userEvent.setup();
render(Grid, {
props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
});
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
await user.keyboard('{End}');
await vi.waitFor(() => {
expect(screen.getAllByRole('gridcell')[2]).toHaveFocus();
});
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('first focusable cell has tabIndex="0" by default', () => {
render(Grid, {
props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
});
const firstCell = screen.getAllByRole('gridcell')[0];
expect(firstCell).toHaveAttribute('tabindex', '0');
});
it('other cells have tabIndex="-1"', () => {
render(Grid, {
props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
});
const cells = screen.getAllByRole('gridcell');
expect(cells[0]).toHaveAttribute('tabindex', '0');
expect(cells[1]).toHaveAttribute('tabindex', '-1');
});
it('columnheader cells are not focusable', () => {
render(Grid, {
props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
});
const headers = screen.getAllByRole('columnheader');
headers.forEach((header) => {
expect(header).not.toHaveAttribute('tabindex');
});
});
});
// 🔴 High Priority: Selection
describe('Selection', () => {
it('Space toggles selection', async () => {
const user = userEvent.setup();
render(Grid, {
props: {
columns: createBasicColumns(),
rows: createBasicRows(),
ariaLabel: 'Users',
selectable: true,
},
});
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
expect(firstCell).toHaveAttribute('aria-selected', 'false');
await user.keyboard(' ');
await vi.waitFor(() => {
expect(firstCell).toHaveAttribute('aria-selected', 'true');
});
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(Grid, {
props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
}); Resources
- WAI-ARIA APG: Grid Pattern (opens in new tab)
- WAI-ARIA APG: Data Grid Examples (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist