A dense transactions table with correct sort semantics, tabular figures and an announced sort state.
Written by
Ahmed Amr
Year
2026
Status
Prototype
Built with
React, TypeScript, ARIA
What does it take for a sortable table to be genuinely usable by keyboard and screen reader, rather than merely passing an automated audit?
In this article4
Loading lab note
Sortable transactions table
Sample transactions, sortable by column.
TXN-4825
Cedar & Vine Ltd
£12,680.40
Settled
TXN-4824
Alto Manufacturing
US$219.05
Returned
TXN-4822
Nord Logistik GmbH
€84,900.00
Screening
TXN-4823
Fairline Studios
£3,400.00
In transit
TXN-4821
Meridian Supply Co
US$1,250.75
Settled
Sorted by Value date, descending. 5 rows.
What I was testing
Automated accessibility checks pass on a lot of tables that are miserable to
actually use. The gap is mostly in three places.
The sort control is often not a control. A <th> with an onClick is not
focusable and not operable from the keyboard. The control has to be a real
<button>, and it belongs inside the header cell so the accessible name stays
attached to the column.
aria-sort goes on the header cell, not the button. Only the currently
sorted column may carry a value other than none. More than one is a
contradiction, and assistive technology reports it as one.
Nothing announces the result. Sorting reorders content far away from the
control that triggered it. Without a live region, a screen-reader user presses a
button and receives no confirmation that anything happened at all.
Sort semanticstransactions-table.tsx
<th scope="col" // `aria-sort` belongs on the header cell, and only the currently sorted // one may carry a value other than "none". aria-sort={isSorted ? direction : "none"}> <button type="button" onClick={() => toggle(column.key)}> {column.label} </button></th>
Density decisions
Show thirty rows rather than paginate at eight
Chose
Tight row height, aligned columns, one-pixel rules, no per-row card.
Trade-off
It looks less spacious in a screenshot and gives up the visual generosity of card layouts. But finding the one wrong payment among forty is a scanning task, and pagination turns scanning into memory work.
Amounts are right-aligned with tabular figures. That combination is what makes
an outlying value visible as a shape in the column before it is read as a
number. The digits line up, so a longer number is physically wider in a way the
eye catches. Proportional figures destroy that, which is why so many financial
tables are harder to scan than they should be.
Accessibility notes
scope="col" and scope="row", with the reference as the row header.
A <caption>, visually hidden, describing what the table contains.
Live region reporting the sorted column, direction and row count.
Focus visible on every header button.
Still open
Column-width persistence and horizontal scroll behaviour on narrow viewports.
The current answer is to scroll the table inside its own container. It is honest
but unsatisfying. A genuinely responsive dense table probably needs to drop columns
by priority, which requires knowing which columns matter for the task at hand.