# Editable data grids a keyboard can't move through or edit

Source: https://easeweb.dev/learn/editable-grids
Topics: Tables and data > Editable grids can be moved through and edited with a keyboard (WCAG 2.1.1, 4.1.2)
The fix: Either put a field in each editable cell, named by its row and column headers with aria-labelledby, which Tab reaches with no script; or make the table a full ARIA grid: role="grid", one Tab stop that the arrow keys move from cell to cell, and Enter or F2 to open a field named by the cell's headers, with Escape to cancel.
Test with: keyboard, screen reader, forced colors, zoom
References: https://www.w3.org/WAI/ARIA/apg/patterns/grid/ https://www.w3.org/WAI/ARIA/apg/patterns/grid/examples/data-grids/ https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/ https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F15 https://www.w3.org/WAI/WCAG22/Techniques/failures/F59 https://www.w3.org/TR/wai-aria-1.2/#grid

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A shop's back office has a stock sheet: a table with three columns, Item, Price and In stock, and a row for each product. Its headers are `th` cells with `scope`, so the table itself is sound. Click a price and it turns into a field; type a new one, press Enter, and the table shows it. Put the mouse aside and press Tab: focus goes straight past the table to Save changes. The cells take no focus, so no price can be changed. Nothing says that one could be: Chrome exposes each price as a plain table cell, the role for content, not for a control. And when a click does open a cell, the field the script puts there has no name of its own: nothing in the code says which item or which column it edits.

The team then makes it a grid the quick way: `role="grid"` on the table, and nothing else. Chrome now exposes the prices as grid cells, a role that promises a widget with one Tab stop and arrow keys that move from cell to cell. Nothing delivers it: no cell takes focus, Tab still passes the table, and there is nowhere to press an arrow key. The field a click opens still has no name.

## Why it fails

Success criterion 2.1.1 Keyboard asks that everything the mouse can do, the keyboard can do. A price is changed only by clicking a cell that takes no focus, so without a mouse no value can be changed at all. `role="grid"` changes nothing here: the cells still take no focus, and the arrow keys a grid promises do nothing.

Success criterion 4.1.2 Name, Role, Value asks that a control's name and role can be determined by software. The prices are controls: a click turns each into a field. In the first table, each is exposed only as a table cell, with nothing to say it can be changed. W3C documents this as failure F59, script that makes an element a control without giving it a role, there for a div or a span, and more broadly as failure F15, a custom control that doesn't use the accessibility API. With `role="grid"`, the cells take a role that can be interactive, but no key ever reaches them. In both tables, the field a click puts in a cell is made by the script with no label, so it has no name: what a screen reader gets from the field itself is an edit field and a number, and nothing that says which cell it is.

## Who is affected

Sheets like this sit behind many admin screens: stock and prices, timesheets, budgets, seating plans, bulk edits of orders. Often they are the only way to change those values, so a sheet the keyboard can't enter is a job someone can't do.

## What automation and AI miss

axe passes both tables. A `td` with a click handler gives a rule nothing to check, and `role="grid"` on a table with header cells is valid markup. No rule checks whether a cell takes focus or whether an arrow key moves it. axe's `label` rule would flag the field a click opens, but that field is not in the page until someone clicks, so a scan of the page as it loads never sees it.

The audit presses the keys. In each grid, it checks whether any cell or anything inside one takes focus from Tab; if none does, no cell can be reached, and that is reported. If one cell is the Tab stop, it focuses that cell and presses the arrow keys, Home and End; if focus never moves, the other cells can't be reached, and that is reported too. It sends to review a grid in which more than one cell is a Tab stop while the arrows do nothing, a table whose cells are drawn with a pointer, cell or text cursor but take no focus and hold no field, and a container named as a grid or sheet, built from divs, with cells like that. A table of clickable cells with no cursor of their own is not found, and no check opens a cell for editing to hear what the field is called: a person checks that.

The second table is what an AI fix can produce: it adds `role="grid"` and stops there. A fix can also put `tabindex="0"` on every cell, which gives each one a Tab stop but still no key that opens it.

## Before: a table whose cells open only on click

```html
<h2 id="stock-title">Stock</h2>
<table class="sheet"
  aria-labelledby="stock-title">
  <thead>
    <tr>
      <th scope="col">Item</th>
      <th scope="col">Price</th>
      <th scope="col">In stock</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Oat milk</th>
      <td>2.40</td>
      <td>36</td>
    </tr>
    <tr>
      <th scope="row">Paper cups</th>
      <td>0.12</td>
      <td>500</td>
    </tr>
    <tr>
      <th scope="row">Espresso beans</th>
      <td>14.00</td>
      <td>8</td>
    </tr>
  </tbody>
</table>
<button type="button">Save changes</button>
```

```css
.sheet {
  border-collapse: collapse;
}
.sheet th,
.sheet td {
  padding: 0.5rem;
  border-bottom: 1px solid #ddd;
  text-align: left;
}
.sheet td {
  cursor: cell;
}
.sheet input {
  width: 5rem;
  font: inherit;
}
```

```js
function edit(cell) {
  if (cell.querySelector('input')) return;
  const input = document.createElement('input');
  input.value = cell.textContent;
  cell.replaceChildren(input);
  input.focus();
  input.addEventListener('keydown', (event) => {
    if (event.key === 'Enter') input.blur();
  });
  input.addEventListener('blur', () => {
    cell.textContent = input.value;
  });
}
for (const cell of document.querySelectorAll('.sheet td')) {
  cell.onclick = () => edit(cell);
}
```

The cells respond only to `click` and take no focus, so no key reaches them. A `td` is a table cell, so nothing says it can be changed, and the field the script makes has no label.

## Before: role="grid" added to the same table

```html
<h2 id="stock-title">Stock</h2>
<table class="sheet" role="grid"
  aria-labelledby="stock-title">
  <thead>
    <tr>
      <th scope="col">Item</th>
      <th scope="col">Price</th>
      <th scope="col">In stock</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Oat milk</th>
      <td>2.40</td>
      <td>36</td>
    </tr>
    <tr>
      <th scope="row">Paper cups</th>
      <td>0.12</td>
      <td>500</td>
    </tr>
    <tr>
      <th scope="row">Espresso beans</th>
      <td>14.00</td>
      <td>8</td>
    </tr>
  </tbody>
</table>
<button type="button">Save changes</button>
```

```css
.sheet {
  border-collapse: collapse;
}
.sheet th,
.sheet td {
  padding: 0.5rem;
  border-bottom: 1px solid #ddd;
  text-align: left;
}
.sheet td {
  cursor: cell;
}
.sheet input {
  width: 5rem;
  font: inherit;
}
```

```js
function edit(cell) {
  if (cell.querySelector('input')) return;
  const input = document.createElement('input');
  input.value = cell.textContent;
  cell.replaceChildren(input);
  input.focus();
  input.addEventListener('keydown', (event) => {
    if (event.key === 'Enter') input.blur();
  });
  input.addEventListener('blur', () => {
    cell.textContent = input.value;
  });
}
for (const cell of document.querySelectorAll('.sheet td')) {
  cell.onclick = () => edit(cell);
}
```

The only difference is the role. It makes every `td` a grid cell, and promises a grid with one Tab stop and the arrow keys, but nothing delivers it: no cell has a `tabindex`, and no script handles a key. Tab still passes every cell, the field a click opens still has no name, and axe passes it.

## After: a field in each cell

```html
<h2 id="stock-title">Stock</h2>
<table class="sheet"
  aria-labelledby="stock-title">
  <thead>
    <tr>
      <th scope="col" id="col-item">Item</th>
      <th scope="col" id="col-price">Price</th>
      <th scope="col" id="col-stock">In stock</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row" id="row-oat">Oat milk</th>
      <td>
        <input value="2.40"
          aria-labelledby="row-oat col-price">
      </td>
      <td>
        <input value="36"
          aria-labelledby="row-oat col-stock">
      </td>
    </tr>
    <tr>
      <th scope="row" id="row-cups">Paper cups</th>
      <td>
        <input value="0.12"
          aria-labelledby="row-cups col-price">
      </td>
      <td>
        <input value="500"
          aria-labelledby="row-cups col-stock">
      </td>
    </tr>
    <tr>
      <th scope="row" id="row-beans">Espresso beans</th>
      <td>
        <input value="14.00"
          aria-labelledby="row-beans col-price">
      </td>
      <td>
        <input value="8"
          aria-labelledby="row-beans col-stock">
      </td>
    </tr>
  </tbody>
</table>
<button type="button">Save changes</button>
```

```css
.sheet {
  border-collapse: collapse;
}
.sheet th,
.sheet td {
  padding: 0.5rem;
  border-bottom: 1px solid #ddd;
  text-align: left;
}
.sheet input {
  width: 5rem;
  font: inherit;
  border: 1px solid #767676;
  border-radius: 4px;
}
```

```js
// No script: each field is native, so Tab, typing
// and the browser's own undo already work.
```

Each value is a native field in a table cell, so Tab reaches every one in reading order, typing changes it, and the browser does the rest: the click script goes. A field inside a table cell takes no name from the table's headers, so `aria-labelledby` names each one from its row's and its column's header: "Paper cups Price, edit, 0.12". The fields keep a visible border, so they still look like something to type in.

## After: a full ARIA grid

```html
<h2 id="stock-title">Stock</h2>
<table class="sheet" role="grid"
  aria-labelledby="stock-title">
  <thead>
    <tr>
      <th scope="col" id="col-item">Item</th>
      <th scope="col" id="col-price">Price</th>
      <th scope="col" id="col-stock">In stock</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row" id="row-oat">Oat milk</th>
      <td tabindex="0">2.40</td>
      <td tabindex="-1">36</td>
    </tr>
    <tr>
      <th scope="row" id="row-cups">Paper cups</th>
      <td tabindex="-1">0.12</td>
      <td tabindex="-1">500</td>
    </tr>
    <tr>
      <th scope="row" id="row-beans">Espresso beans</th>
      <td tabindex="-1">14.00</td>
      <td tabindex="-1">8</td>
    </tr>
  </tbody>
</table>
<button type="button">Save changes</button>
```

```css
.sheet {
  border-collapse: collapse;
}
.sheet th,
.sheet td {
  padding: 0.5rem;
  border-bottom: 1px solid #ddd;
  text-align: left;
}
.sheet td {
  cursor: cell;
}
.sheet td:focus-visible {
  outline: 2px solid;
  outline-offset: -2px;
}
.sheet input {
  width: 5rem;
  font: inherit;
}
```

```js
const grid = document.querySelector('[role="grid"]');
const rows = [...grid.querySelectorAll('tbody tr')];
const cells = rows.map((row) => [...row.cells].slice(1));
const heads = [...grid.querySelectorAll('thead th')];
// One Tab stop: the cell last moved to.
function moveTo(cell) {
  if (!cell) return;
  for (const other of cells.flat()) {
    other.tabIndex = other === cell ? 0 : -1;
  }
  cell.focus();
}
// Enter or F2 opens a field named by the cell's headers.
function edit(cell) {
  const was = cell.textContent;
  const input = document.createElement('input');
  input.value = was;
  const rowHead = cell.parentElement.querySelector('th');
  const colHead = heads[cell.cellIndex];
  const label = `${rowHead.id} ${colHead.id}`;
  input.setAttribute('aria-labelledby', label);
  cell.replaceChildren(input);
  input.focus();
  let open = true;
  const close = (keep) => {
    if (!open) return;
    open = false;
    cell.textContent = keep ? input.value : was;
  };
  input.addEventListener('keydown', (event) => {
    // While editing, the keys belong to the field.
    event.stopPropagation();
    if (event.key === 'Enter' || event.key === 'F2') {
      event.preventDefault();
      close(true);
      moveTo(cell);
    } else if (event.key === 'Escape') {
      close(false);
      moveTo(cell);
    }
  });
  // Tab or a click elsewhere keeps the change.
  input.addEventListener('blur', () => close(true));
}
grid.addEventListener('keydown', (event) => {
  const cell = event.target.closest('td');
  if (!cell) return;
  const r = rows.indexOf(cell.parentElement);
  const c = cells[r].indexOf(cell);
  switch (event.key) {
    case 'ArrowRight': moveTo(cells[r][c + 1]); break;
    case 'ArrowLeft': moveTo(cells[r][c - 1]); break;
    case 'ArrowDown': moveTo(cells[r + 1]?.[c]); break;
    case 'ArrowUp': moveTo(cells[r - 1]?.[c]); break;
    case 'Home':
      moveTo(event.ctrlKey ? cells[0][0] : cells[r][0]);
      break;
    case 'End': {
      const row = event.ctrlKey ? cells.at(-1) : cells[r];
      moveTo(row.at(-1));
      break;
    }
    case 'Enter':
    case 'F2': edit(cell); break;
    default: return;
  }
  event.preventDefault();
});
grid.addEventListener('click', (event) => {
  const cell = event.target.closest('td');
  if (!cell || cell.querySelector('input')) return;
  moveTo(cell);
  edit(cell);
});
```

The table keeps its rows and headers, and `role="grid"` now gets what it promises. The whole grid is one Tab stop, and the keys follow the ARIA Authoring Practices data grid: the arrows move one cell, Home and End go to the ends of the row, Ctrl + Home and Ctrl + End to the first and last cell. Enter or F2 opens a field in the cell, named "Paper cups Price"; Enter or F2 keeps the change and returns to the cell, and Escape puts the old value back. A click still opens a cell, through the same code. The Authoring Practices note that header cells with no function of their own, such as sorting, need not take focus.

## Which option to use

Both pass. A field in each cell needs no script and no new keys to learn: every field is a Tab stop, and typing, selecting text and the browser's own undo already work. Choose it for a sheet of a few rows, like this one, or any form laid out as a table. The grid puts the whole sheet behind one Tab stop, so it suits a sheet too long to tab through field by field, such as hundreds of rows of orders, or an app whose users expect spreadsheet keys. Then it needs all of it: one Tab stop, every key and a named field in each cell it opens.

## Implementation decisions

The Authoring Practices also suggest that typing a letter or digit in a cell starts editing it, as spreadsheets do, and Page Up and Page Down for long grids; this sketch has neither. A column that can't be changed, such as a total worked out from the others, keeps its focus but takes `aria-readonly="true"` on its cells. `aria-selected` belongs only to a grid where cells or rows can be chosen, apart from the focus.

Don't put `tabindex="0"` on every cell of a grid instead of handling the keys: Tab then reaches every cell, but a grid's cells promise the arrow keys, and no key opens them. If people should Tab from cell to cell, give each cell a field, as in the first fix.

The structure the table already has is a separate matter. A grid built from divs needs a row around each row's cells, and one that draws only the rows in view needs `aria-rowcount` on the grid and `aria-rowindex` on each row, so that a screen reader can say where each cell is; both belong to the same topic as [data tables with headers that are only bold text](/learn/table-headers). Rows that open into child rows, such as threads in an inbox, are a tree grid, a pattern of its own, and a cell that holds a rich text editor needs that editor's own keys.

## Verify the fix

1. Put the mouse aside and Tab into the sheet. Reach every editable cell, change a value and keep it, then change one and cancel; in a grid, check that one Tab leaves it.
2. In a grid, check every key: the arrow keys, Home, End, Ctrl + Home and Ctrl + End move one cell or to the ends, as the data grid pattern describes; Enter and F2 open a cell; and, in this guide's script, Enter or F2 keeps the change and Escape cancels it, with focus back on the cell each time.
3. With a screen reader, Tab to a cell's field, or open a cell for editing: it is announced as an edit field with its item and its column, then its value. In a grid, each cell is read with its headers as focus moves to it.
4. Turn on forced colors (Windows Contrast themes) and zoom to 200%. The focus ring on each cell, and the field that opens in it, are still visible, and no column is cut off.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the sheet, its component or its library changes.

## Limits

This is a starting pattern, not a guarantee for every application. The grid script is a minimal sketch: it has no typing to start editing, no Page Up or Page Down, no selection, no copy and paste, and no loading of rows on demand. Sorting by a column is a separate failure, covered by the topic on sortable tables saying which column is sorted, and which way. The same one-Tab-stop pattern, for a sidebar, is shown in [tree views keyboards can't open and screen readers flatten](/learn/tree-views). Data grid components in libraries vary: check that the one you use reaches every cell and handles every key before relying on it. Test the complete journey with your target assistive technology.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
