Components / structure
DataGrid
The APG draws the same line: its own layout-grids example is excluded from the port as a use of the pattern rather than the pattern.
Summary
A table whose cells take focus — arrow keys move in two dimensions and the whole grid costs one tab stop.
| APG pattern | Grid |
| Built on | native elements |
| Status | stable |
| Since | 0.2.0 |
<DataGrid label="Section layout" columns={…} rows={sections} onCellActivate={(row, col) => edit(row, col)} />
The export is DataGrid and the anatomy stays data-se="grid". Grid names the layout primitive, which places children and carries no role, no keyboard model and no announcement.
Anatomy
A table carrying role="grid", shared with Treegrid so one set of rules dresses both.
| Part | Element | Required |
|---|---|---|
cell | <td> | yes |
columnheader | <th> | yes |
row | <tr> | yes |
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
columns | DataGridColumn[] | required | The columns, in display order. Mark the one that identifies its row as header so a screen reader can say which row a value belongs to. |
rows | Record<string, unknown>[] | required | One object per row, keyed by column key. |
label | string | required | Names the grid. A grid is a place a reader enters, and entering an unnamed one tells them nothing. |
getRowId | (row: Record<string, unknown>) => string | none | A stable id per row. Without it the grid keys on index, which moves the focused cell when rows are inserted above it. |
onCellActivate | (row: Record<string, unknown>, column: DataGridColumn) => void | none | Called when a cell is activated with Enter. This is the hook for interaction mode — what happens inside the cell is the caller's. |
rowCount | number | none | Total rows in the dataset when this grid renders only a window over it. Sets aria-rowcount/aria-colcount and switches rows to true aria-rowindex — without it a virtualised grid announces "1 of 20" instead of "101 of 340". |
rowOffset | number | 0 | Index of the first rendered row within that dataset. |
States
| State | Expressed as | |
|---|---|---|
| Focused | :focus-visible | |
| Selected | aria-selected |
Behavior
Two modes. In navigation mode the arrows move between cells and the grid is one tab stop. In interaction mode the arrows belong to whatever widget is inside the cell.
Home and End go to the row's ends, Ctrl with either to the grid's.
Accessibility
A column marked header renders role="rowheader", so a screen reader can say which row a value belongs to instead of reading a bare number.
Only when cells are genuinely navigable — a spreadsheet, a date picker, a matrix of settings. A table that is merely read is a DataTable; putting grid semantics on it turns every cell into an obstacle for no gain, and a screen-reader user then has to escape a grid they never wanted to enter.
Keyboard
| Key | Result |
|---|---|
Arrow keys | Move one cell in two dimensions |
Home / End | Jump to the ends of the row |
Ctrl + Home / End | Jump to the ends of the grid |
Enter | Activates the cell, entering interaction mode |
Escape | Returns to navigation mode |
Roles, states and properties
| Attribute | Set by |
|---|---|
aria-colcount | set at render |
aria-colindex | set at render |
aria-label | set at render |
aria-rowcount | set at render |
aria-rowindex | set at render |
role | always grid, always row, always columnheader, set at render |
Focus
One tab stop for the whole grid. Two modes: in navigation mode the arrows move between cells; in interaction mode they belong to whatever widget the cell holds.
Screen reader
Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.
Contrast and target size
npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.
It sets no target size of its own; anything interactive inside it brings its own.
Tested
test/apg/batch-c.test.jsxtest/apg/partials-sweep.test.jsxtest/apg/table-narrowing.test.jsxtest/browser/axe.spec.mjstest/browser/keyboard.spec.mjstest/browser/site.spec.mjstest/browser/upstream-combobox.spec.mjstest/browser/upstream-composites.spec.mjstest/browser/upstream-forms-menubar.spec.mjstest/browser/upstream-overlays.spec.mjstest/browser/upstream-toolbar-tree.spec.mjs
Known gaps
- 3 upstream refs — layout grids — a use of the pattern, not the pattern
Appearance
| Token | Used for |
|---|---|
--se-accent-soft | background |
--se-border | border, border-block-end |
--se-border-width | border, border-block-end |
--se-focus-color | outline |
--se-focus-width | outline, outline-offset |
--se-radius-md | border-radius |
--se-space-2 | padding |
--se-space-3 | padding |
--se-surface-selected | background |
--se-surface-sunk | background |
--se-text | color |
--se-text-muted | color |
--se-text-sm | font-size |
Motion
None.
Content
Column labels are nouns. A grid is read cell by cell, so a header that needs a sentence will be heard as one on every cell beneath it.
Usage
If your rows form a hierarchy, that is a Treegrid. If the cells are only read, that is a DataTable.
Notes
Renamed from Grid on 2026-08-22. The two were unrelated things sharing a word, and a consumer reaching for a tile layout and finding this one would ship a page whose reader has to escape a grid. The anatomy did not move — data-se="grid" is what a Vue or Astro consumer writes and what Treegrid renders — so no CSS rule changed and only the export broke, at 0.2.0 where a clean break costs a minor bump.
The APG draws the same line: its own layout-grids example is excluded from the port as a use of the pattern rather than the pattern.