Making a HTML table keyboard navigable

Published: Tue Dec 13 2022

I was working on an app containing a HTML table that I wanted to make usable with the keyboard. That is, I wanted the user to have the ability to tab into the table, and then use the arrow keys to navigate the table. Many table libraries handle this out of the box in more sophisticated way, but I needed a headless solution for my use case.

The example here is a TypeScript/React one, but the principles apply more broadly.

Making the cells focusable

The first step was to make each table cell (td) focuable, so that the user could tab into the table. I did this by adding the tabindex attribute to each cell like so:

<table>
  <tr>
    <td tabIndex={0}></td>
    <td tabIndex={0}></td>
    <td tabIndex={0}></td>
    <td tabIndex={0}></td>
    <td tabIndex={0}></td>
  </tr>
  <tr>
    <td tabIndex={0}></td>
    <td tabIndex={0}></td>
    <td tabIndex={0}></td>
    <td tabIndex={0}></td>
    <td tabIndex={0}></td>
  </tr>
</table>

And here's the result:

Grid with tab index

Now, you can tab into the table, but still can't navigate across is with a keyboard.

Navigating the table with the arrow keys

The next step is to make the arrow keys work. For this, we can add a keydown event listener to the table, and then using the event.key property to determine which key was pressed. Once we've identified the pressed key, we can select the adjacent cell and focus it. Here's the code:

const handleKeyDown = (
  event: React.KeyboardEvent<HTMLTableElement>,
) => {
  const currentCell = event.target as HTMLTableCellElement
  const table =
    currentCell.offsetParent as HTMLTableElement | null
  const currentCellParentRow =
    currentCell.parentNode as HTMLTableRowElement | null

  if (!table || !currentCellParentRow) return

  let cellToFocus: HTMLTableCellElement | null = null

  if (event.code == 'ArrowLeft') {
    cellToFocus =
      table.rows[currentCellParentRow.rowIndex].cells[
        currentCell?.cellIndex - 1
      ]
    cellToFocus?.focus()
  } else if (event.code == 'ArrowRight') {
    cellToFocus =
      table.rows[currentCellParentRow.rowIndex].cells[
        currentCell?.cellIndex + 1
      ]
    cellToFocus?.focus()
  } else if (event.code == 'ArrowUp') {
    cellToFocus =
      table.rows[currentCellParentRow.rowIndex - 1].cells[
        currentCell?.cellIndex
      ]
    cellToFocus?.focus()
  } else if (event.code == 'ArrowDown') {
    cellToFocus =
      table.rows[currentCellParentRow.rowIndex + 1].cells[
        currentCell?.cellIndex
      ]
    cellToFocus?.focus()
  } else if (event.code == 'Escape') {
    currentCell.blur()
  }
}

const KeyboardGridWithNav = () => {
  return (
    <table onKeyDown={handleKeyDown}>
      <tr>
        <td tabIndex={0}></td>
        <td tabIndex={0}></td>
        <td tabIndex={0}></td>
        <td tabIndex={0}></td>
        <td tabIndex={0}></td>
      </tr>
      <tr>
        <td tabIndex={0}></td>
        <td tabIndex={0}></td>
        <td tabIndex={0}></td>
        <td tabIndex={0}></td>
        <td tabIndex={0}></td>
      </tr>
    </table>
  )
}

And here's the result:

Grid with tab index and keyboard navigation

With a larger grid or table, you might prefer an escape hatch to focus out of the table. For this, the event handler for the Escape key, which will blur (i.e., unfocus) the current cell. The example can be extended to do more, such as triggering a callback on hiting the Enter key.

And that's it. We now have an accessible and type safe way to navigate a HTML table with the keyboard.