Dense numbers people compare across rows and columns — an option chain, a statement. A real <table>, with the parts tables don't do on their own: a reachable scroll area, sticky key columns, sorting that announces itself, and row markers that don't rely on colour.
Already rendering a table yourself? Wrap it: with no columns set, the element keeps your markup and adds the labelled, keyboard-reachable scroll region.
Sticky column background, marked-row border, second sticky header row offset.
Key
Type
Description
key, label
string
Data key and visible header.
srLabel
string
Replaces the header for screen readers (“Calls open interest” for “OI”).
group
string
Group id. Grouped headers get the group name as hidden prefix text.
rowHeader
boolean
Render cells as <th scope="row"> — the cell that names the row.
sticky
boolean
Stays visible while scrolling sideways (sticks to whichever edge it reaches).
align
start | end | center
Numbers: end.
sortable, sortValue
boolean, function
Header becomes a button; sortValue(row) overrides the value sorted on.
format
function
row => string | Node.
srSuffix
function
row => string, appended as visually hidden text (“highest”, “paid out”).
cellClass, cellStyle, headerClass
function / string
Styling hooks. Light DOM, so app CSS (including Tailwind) applies.
Keyboard
Key
Action
Tab
Reaches the table's scroll area when it overflows (no extra stop when it fits), then any sortable headers.
←→↑↓
With the scroll area focused, scroll it — so columns past the edge are reachable without a mouse.
Enter / Space
On a sortable header: sort ascending, then descending.
Screen reader table keys
Kept intact (e.g. NVDA/JAWS CtrlAlt + arrows; VoiceOver VO + arrows), because this is a real table — see below.
Accessibility notes
Why a table, not role="grid"
A grid turns the table into a single widget: arrow keys move a cell cursor and screen readers switch to application mode. That's right for spreadsheets and cells you edit or act on. For reading and comparing numbers it's worse — users lose their screen reader's own table navigation and header announcements. So this component is a table, and grid behaviour is something to opt into only when cells become interactive (for example “select a strike to add a leg”).
Repeated column names get their group as hidden text
An option chain has “LTP”, “OI” and “IV” twice — once under Calls, once under Puts. scope="colgroup" is the standard answer, but screen readers announce group headers unevenly. So each grouped header also carries its group as visually hidden prefix text: the cell is announced as “Calls LTP”, not just “LTP”, in every screen reader.
The at-the-money row is marked with text. The app showed it with a yellow cell and an amber border only. Here the row header gets a visible “ATM” badge plus “at the money” for screen readers, read with every cell in that row.
The scroll area is a labelled region named by the caption, focusable only when it actually overflows (the audit found it unreachable by keyboard on phones).
Sorting is announced (“Sorted by Amount, descending”) through one shared polite live region, and only the sorted column carries aria-sort.
Light DOM on purpose: a table split across shadow roots loses its header–cell relationships, so this component renders into the document.
Reflow: wide tables scroll inside their region instead of making the whole page scroll sideways.
Usage guidance
✓ Do
Write a caption that says what the table is (“Current account — September”).
Mark the column that names each row as rowHeader.
Put meaning from colour into text with srSuffix or rowMarker.
✗ Don't
Use it for layout.
Announce updates for fast-ticking data — once a minute is plenty.
Make every column sortable “just because”; sort what people actually compare.
Using it in React
React 19 sets columns, rows and rowMarker as properties, so functions and arrays arrive intact. Keep columns stable with useMemo.