Skip to main content

Accessibility

Good accessibility isn't just compliance — it's building software that works for everyone. A keyboard user in a tight workflow shouldn't have to reach for the mouse. A screen reader user deserves the same experience as anyone else.

OGrid is built with this in mind. Keyboard navigation, screen reader support, high contrast mode, and ARIA semantics are all baked in — you don't configure them, you just benefit from them.

What's built in by default​

You get all of this without any extra setup:

  • Full keyboard navigation — arrow keys, Tab, Home/End, Ctrl+Arrow, all working like a spreadsheet
  • Screen reader announcements for sort, filter, edit, selection, and pagination changes
  • Proper ARIA roles and attributes on the grid, rows, cells, and headers
  • Roving focus: the active cell holds real keyboard focus, so screen readers announce each cell as you move
  • :focus-visible indicators so keyboard users always know where they are
  • Focus trapping in dropdowns and popovers (Escape always gets you out)
  • High contrast mode support via CSS custom properties with system color fallbacks
  • Semantic HTML — real <table>, <thead>, <th>, <td> elements that assistive tech understands

The one thing you do need to add: an aria-label on the grid. It's one prop.

<OGrid
data={products}
columns={columns}
getRowId={(item) => item.id}
aria-label="Product catalog"
/>

Keyboard navigation​

OGrid uses Excel-style keyboard shortcuts, so if your users know spreadsheets, they already know how to navigate.

Moving around​

KeyAction
↑ ↓ ← →Move active cell
Ctrl+↑ / Ctrl+↓Jump to first/last row in column
Ctrl+← / Ctrl+→Jump to first/last column in row
HomeFirst column in row
EndLast column in row
Ctrl+HomeTop-left cell
Ctrl+EndBottom-right cell

Selecting cells​

KeyAction
Shift+↑↓←→Extend selection
Shift+Home / Shift+EndExtend selection to row edge
Shift+Ctrl+Home / Shift+Ctrl+EndExtend to grid corner
Ctrl+ASelect all cells
← from the first columnMove to the row's checkbox cell (when rowSelection="multiple")
Space (on the checkbox cell)Toggle that row's selection
Shift+Space (on the checkbox cell)Select the rows from the last toggled row to this one, like Shift+click on a checkbox
Shift+Space (on any other cell)Toggle the active row's selection

Editing​

KeyAction
Enter or F2Start editing the active cell
Space (on an editable boolean cell)Toggle the checkbox
EscapeCancel edit, revert value
Enter (while editing)Commit and move down
Tab / Shift+TabCommit and move right/left

Everything else​

KeyAction
Ctrl+C / Ctrl+X / Ctrl+VCopy, cut, paste
DeleteClear selected cells
Ctrl+Z / Ctrl+YUndo / redo
Shift+F10Open context menu
Enter / Space on column headerToggle sort
← → on a column resize handleResize the column (Shift for 1px steps; Home = min width, Escape = restore)

Focus model​

OGrid follows the WAI-ARIA grid pattern with a roving tabindex:

  • One tab stop. Exactly one body cell has tabindex="0": the active cell, or the first data cell before any cell is active. Every other body cell has tabindex="-1". Tab enters the grid on that cell; Tab from the last cell (or Shift+Tab from the first) leaves it. Header controls (sort and filter buttons, column menus, resize handles) keep their own tab stops.
  • In-cell controls are not tab stops. The row selection checkboxes and boolean cell checkboxes have tabindex="-1", so Tab never stops inside a row. The cell is what you focus: with rowSelection="multiple", ← from the first data column moves to the row's checkbox cell, Space there toggles the row, and Shift+Space selects the range from the last toggled row. Space on an editable boolean cell toggles it. A mouse click on a checkbox still works as before.
  • The select-all checkbox stays a tab stop. It sits in the header row with the other header controls, which are tab stops before the grid body; the header is not part of arrow-key navigation, so making only this checkbox reachable by arrows would leave it the one header control a keyboard user can't Tab to. Tab to it and press Space.
  • Focus follows the active cell. Arrow keys, Ctrl+Arrow, Home/End, Page Up/Down, Tab, clicks and Shift+click all move real DOM focus to the active cell's <td> (Shift+Arrow and Shift+click keep focus on the anchor cell while the range grows). Focusing a cell, for example by Tabbing in or from a screen reader, makes it active.
  • Editors. While a cell is being edited, its editor (inline, or a popover editor rendered in a portal) has focus and receives every key. When the edit commits or is cancelled, focus returns to the active cell. The formula bar (formulas) works the same way: Enter commits and Escape cancels, and both return focus to the active cell. If you click somewhere else instead, focus stays where you clicked.
  • Escape clears the active cell and selection but leaves focus on the cell, which stays the tab stop.
  • Virtual scrolling. If you scroll the focused row out of the DOM, focus moves to the grid's scroll container (role="region") instead of falling back to the page body. That container becomes the tab stop (tabindex="0") until the row renders again, and then focus returns to the cell. Keys pressed while the container has focus still navigate, and bring the active cell back into view.
  • Focus indicator. The focused cell shows the active-cell outline. There is no second focus ring on top of it. Keyboard focus on a cell that has no outline, such as the active cell inside a multi-cell range, gets the same outline. The scroll container shows a focus ring only when it holds keyboard focus.

Using the headless hooks instead of <OGrid>? useGridFocus gives you the same model through getCellProps.

ARIA markup​

The grid renders with full ARIA attributes so assistive technologies have the context they need.

<div role="region" aria-label="Product catalog" tabindex="-1">
<table role="grid" aria-rowcount="1001" aria-colcount="4">
<thead>
<tr>
<th role="columnheader" aria-sort="ascending" scope="col">
Product Name
</th>
</tr>
</thead>
<tbody>
<tr aria-rowindex="2">
<td aria-colindex="1" tabindex="0">Widget A</td>
<td aria-colindex="2" tabindex="-1">$12.00</td>
</tr>
</tbody>
</table>
</div>

Body cells are native <td> elements, which take the gridcell role inside role="grid". aria-rowindex counts the header rows.

Key attributes and what they do:

AttributePurpose
role="grid"Identifies the interactive grid container
aria-labelGives the grid an accessible name
aria-rowcountTotal rows (useful for server-side pagination where not all rows are in the DOM)
aria-rowindexRow position in the full dataset
aria-sortCurrent sort state on column headers
aria-selectedWhether a row is selected
aria-expandedOpen/closed state of filter dropdowns
aria-current="page"Current page in pagination

The status bar uses role="status" with aria-live="polite" — it announces changes without interrupting what the user is doing.

Screen reader support​

OGrid announces meaningful state changes:

  • Navigation: the focused cell itself, with its column header and row and column position (the exact wording depends on the screen reader)
  • Sort: "Sorted by Product Name, ascending"
  • Edit: "Editing Salary, row 3"
  • Value change: "Salary changed from $68,000 to $72,000"
  • Pagination: "Showing 21 to 40 of 250 rows, page 2 of 13"
  • Selection: "5 rows selected"

Designed for NVDA and JAWS (Windows), VoiceOver (macOS) and Narrator (Windows). The roving focus model is covered by automated browser tests; manual screen reader testing of it is pending, so please report anything that reads incorrectly.

Providing extra context​

For grids embedded in a page with other content, it helps to connect the grid to surrounding descriptive text:

<div>
<h2 id="orders-heading">Recent Orders</h2>
<p id="orders-description">
Your last 30 days of orders. Use arrow keys to navigate, Enter to edit.
</p>
<OGrid
data={orders}
columns={columns}
getRowId={(item) => item.id}
aria-labelledby="orders-heading"
aria-describedby="orders-description"
/>
</div>

If you need to announce dynamic state changes from outside the grid (selection counts, save confirmations), use an ARIA live region alongside it:

function GridWithAnnouncements() {
const [status, setStatus] = useState('');

return (
<>
<div role="status" aria-live="polite" className="sr-only">
{status}
</div>
<OGrid
data={orders}
columns={columns}
getRowId={(item) => item.id}
rowSelection="multiple"
onSelectionChange={({ selectedRowIds }) => {
const count = selectedRowIds.length;
setStatus(`${count} ${count === 1 ? 'order' : 'orders'} selected`);
}}
/>
</>
);
}

The .sr-only pattern keeps the announcement visually hidden but readable by assistive tech:

.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}

Automated accessibility testing​

OGrid's test suite includes jest-axe accessibility tests. You can do the same in your own tests:

import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { OGrid } from '@alaarab/ogrid-react-radix';

expect.extend(toHaveNoViolations);

test('grid has no accessibility violations', async () => {
const { container } = render(
<OGrid
data={products}
columns={columns}
getRowId={(item) => item.id}
aria-label="Product catalog"
/>
);

const results = await axe(container);
expect(results).toHaveNoViolations();
});
npm install --save-dev jest-axe

Manual testing checklist​

Automated tests catch structural issues but can't cover everything. Before shipping:

  • Navigate the entire grid using only the keyboard — no mouse
  • Enable VoiceOver (Cmd+F5) or NVDA and navigate through sort, filter, edit
  • Test at 200% zoom — nothing should overflow or become unusable
  • Toggle Windows High Contrast Mode — all content should remain visible
  • Verify aria-label is set on the grid (one of the most common misses)

Known limitations​

Virtual scrolling: When virtualScroll.enabled is true, only visible rows are in the DOM. The grid sets aria-rowcount and aria-rowindex so screen readers know each row's position in the full dataset, but a screen reader's own browse mode can only read the rows that are rendered. While the focused row is scrolled out of the DOM, focus waits on the grid's scroll container (see Focus model).

Custom cell renderers: If you use renderCell to render custom content inside cells, you're responsible for making that content accessible — ARIA attributes, keyboard interaction, announcements. The grid provides the structure; your custom content needs to carry its own accessibility.

The grid never changes the DOM your renderer returns, so a link or button in a cell is a tab stop of its own unless you say otherwise. That breaks the one-tab-stop model: Tab would stop on every link in every row. Give such controls tabIndex={-1} (they stay clickable) and make the cell the way in from the keyboard. Two patterns from the WAI-ARIA grid pattern work well:

  • One action per cell: handle Enter on the focused cell and run the action. Key events from the focused <td> bubble out of the grid, so a capture-phase handler on an element around <OGrid> sees them first; stopping propagation there keeps the grid from also acting on the key.
  • Several controls in a cell ("widget mode"): on Enter or F2, move focus into the cell's first control; let Tab and Shift+Tab cycle between the cell's controls; on Escape, put focus back on the cell's <td>. Arrow keys typed inside a control stay with the control.
const columns: IColumnDef<Order>[] = [
{
columnId: 'invoice',
name: 'Invoice',
renderCell: (order) => (
// Clickable, but not a tab stop: the cell is.
<a href={order.invoiceUrl} tabIndex={-1}>{order.invoiceNumber}</a>
),
},
];

<div
onKeyDownCapture={(e) => {
if (e.key !== 'Enter') return;
const cell = e.target as HTMLElement;
const link = cell.tagName === 'TD' ? cell.querySelector('a') : null;
if (link) {
e.stopPropagation(); // Enter follows the link instead of starting an edit
link.click();
}
}}
>
<OGrid columns={columns} data={orders} getRowId={(o) => o.id} />
</div>

Resources​

Found an accessibility issue? Open an issue and tag it accessibility with your browser, screen reader version, and reproduction steps.