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.
