Skip to main content

Making Data Tables Keyboard-Navigable

A spreadsheet displayed on a monitor screen
Apr 6, 20262 min readReact, Design Systems, HTML5

The Problem with Styled Tables

Custom table components tend to inherit all of HTML's visual structure and none of its semantic structure. The shared-ui Table had <thead>, <tbody>, <th>, and <td>, so the markup was correct, but it was missing the attributes that tell assistive technology how to interpret that markup.

Then there was onRowClick, a clickable table row that only responded to mouse events. Keyboard users couldn't reach it, and screen reader users had no idea it was interactive at all.

scope="col" — The One Attribute Every Table Needs

Without scope, a screen reader has to guess which header belongs to which column. On a simple table it usually guesses right; on a table with merged cells or an ambiguous layout, it doesn't.

<th scope='col' className={cn('px-4 py-3 font-medium text-text-secondary')}>
  {col.header}
</th>

One attribute, applied to every <th> in the header, and it removes all the ambiguity about the header-to-data relationship.

Caption vs aria-label

A table should identify itself. HTML gives me two options:

  • <caption>: visible, rendered inside the table. Best when the label should be visible to all users.
  • aria-label: invisible, read only by screen readers. Best when the surrounding context already provides a visible heading.

I support both, with caption taking precedence:

<table aria-label={!caption ? ariaLabel : undefined}>
  {caption && (
    <caption className='px-4 py-3 text-left text-sm font-medium'>
      {caption}
    </caption>
  )}

This avoids the common mistake of providing both, which can make screen readers announce the table's identity twice.

Making Clickable Rows Accessible

The onRowClick prop was the real accessibility gap. A <tr> with an onClick handler looks interactive to a mouse user, thanks to hover styles and a pointer cursor, but it's completely invisible to keyboard navigation.

The fix has three parts:

<tr
  tabIndex={onRowClick ? 0 : undefined}
  role={onRowClick ? 'button' : undefined}
  onKeyDown={onRowClick ? (e) => handleRowKeyDown(e, row) : undefined}
  aria-label={onRowClick && getRowAriaLabel ? getRowAriaLabel(row) : undefined}
  className={cn(
    onRowClick && [
      'cursor-pointer hover:bg-surface-tertiary',
      'focus-visible:outline-2 focus-visible:outline-offset-[-2px]',
      'focus-visible:outline-accent',
    ]
  )}
>

tabIndex={0} puts the row in the tab order. role="button" tells screen readers the row is activatable. The keyboard handler fires onRowClick on Enter or Space:

const handleRowKeyDown = (e: KeyboardEvent<HTMLTableRowElement>, row: T) => {
  if (e.key === 'Enter' || e.key === ' ') {
    e.preventDefault();
    onRowClick?.(row);
  }
};

e.preventDefault() on Space is essential; without it the page scrolls out from under you.

The rowKey and getRowAriaLabel Props

Two small API additions that solve real problems:

rowKey replaces array-index keys. When the table data changes through sorting, filtering, or pagination, index-based keys make React remount rows for no reason, so a stable key function avoids the churn:

<tr key={rowKey ? rowKey(row) : i}>

getRowAriaLabel gives clickable rows a meaningful accessible name. Without it a screen reader just announces "button" with no clue what the row actually represents; with it:

// Usage
<Table
  onRowClick={handleRowClick}
  getRowAriaLabel={row => `View details for ${row.name}`}
/>

The Takeaway

Table accessibility is cumulative; no single attribute makes a table accessible on its own. It's scope and caption and keyboard support and focus indicators all working together. The total diff was about 60 lines of component code and 16 new tests, and none of it was clever; it was just the stuff that should have been there all along.