Search topics
Sortable tables that show the sort only as an arrow
Pressing a column header re-sorts the rows and moves an arrow, but no header says it sorts, or which way, so a screen reader hears plain headers.
<svg class="arrow" aria-hidden="true"> The problem
Screen reader users can't tell which column a table is sorted by, or which way, and may not learn that its headers sort at all.
A shop's account page lists a customer's orders in a table with four columns: Order, Date, Total and Status. It opens sorted by date, newest first, with a small arrow pointing down beside "Date". Press "Total" and the rows re-sort by price, highest first, and the arrow moves beside Total; press it again and the list turns round. For a sighted customer, the arrow says it all.
A screen reader user moving along the headers hears "Date" and "Total", read the same way. Nothing says the rows are in date order, or that the newest is first, and after pressing Total nothing says the order changed or which way it now runs. In one common build, the header cell itself takes the click: it is read as a plain column header, so nothing even says it can be pressed.
WCAG 4.1.2 Name, Role, Value asks that every control on a page has a name and a role that assistive technology can read, and that its state can be read too. A sortable header is a control with a state: which way the table is sorted by it. Here the state is shown only to the eye. The arrow is an icon marked aria-hidden="true", as icon libraries draw them, and the classes that show and turn it, sorted-desc and sorted-asc, mean nothing to the browser. ARIA has an attribute for exactly this state, aria-sort, set on the column header the rows are sorted by, and neither build sets it.
When the click is on the header cell itself, as sorttable.js does it, or on a div inside it, as in TanStack Table's sorting example, the control has no role either. A header cell is not a button, so a screen reader reads it as a header and gives no sign that it does anything; this is failure F15, a custom control that doesn't use the accessibility API. The browser lets it happen because a click handler works on any element, and nothing in HTML ties a drawn arrow to a state.
The control's name has a catch. The text of a header is the name of its column, read with every cell under it, so it can't also say "sort by". The ARIA Authoring Practices example says it once instead, in the table's caption: "column headers with buttons are sortable".
People sort a long table to find what they came for: the largest order, the oldest unpaid invoice, the item arriving soonest. When the order isn't said, a screen reader user can still sort, but has to read rows and compare them to learn what the sort did.
1 of 10 groups affected
- No vision (affected)
- Low vision (not affected)
- Colour vision (not affected)
- No hearing (not affected)
- Hard of hearing (not affected)
- No speech (not affected)
- Motor (not affected)
- Reach, strength (not affected)
- Cognitive (not affected)
- Seizures (not affected)
- Without vision Moving along the headers, a screen reader reads "Date" and "Total" the same way, so the person can't tell that the newest order is on top, or that pressing Total put the largest order on top. To find out, they read several rows and compare them, and do it again after every press.
axe and ANDI don't check sort state, so a header with an arrow icon and a header button with no aria-sort both pass. When the only sign of the sort is a picture, code can't tell which column is sorted. When a class names the state, such as sorted-desc, sorting_asc or sorttable_sorted, this audit reports a finding if no header in the table has aria-sort. For every table whose headers look sortable, it sends a lead to press them with a screen reader. A header with an onclick attribute that Tab never reaches is reported as a control the keyboard can't reach; a click handler added from a script, as sorttable.js adds it, is left to that lead. The sorting examples of popular libraries, such as TanStack Table's and the data table in shadcn/ui, draw an arrow and set no aria-sort, and an AI code generator that follows them can write the same.
Try it
Sort the orders by Total, then find out from the panel which way they run.
Broken 1: click Total. The rows re-sort and the arrow moves. Then try Tab: it never reaches the headers. The panel shows what a screen reader says on the header you clicked.
Broken 2: Tab to Date and Total, and press Enter on Total. The panel shows what a screen reader says. Does it tell you the order?
Apply the fix. Tab to Date, then press Enter on Total, twice. Does the panel say which way the rows run?
Broken Can you fix this?
| Order | Date | Total | Status |
|---|---|---|---|
| #1071 | 22 Oct 2026 | $9.90 | Processing |
| #1063 | 18 Oct 2026 | $142.00 | Shipped |
| #1057 | 12 Oct 2026 | $24.50 | Delivered |
| #1042 | 3 Oct 2026 | $86.00 | Delivered |
Screen reader says:
(Click a sortable header)
The fix
Make each header a button, and say the sort with aria-sort.
The header cell takes the click
<table class="orders"> <caption>Your orders</caption> <thead> <tr> <th scope="col">Order</th> <th scope="col" class="sorted-desc" data-key="date"> Date <svg class="arrow" aria-hidden="true">…</svg> </th> <th scope="col" data-key="total"> Total <svg class="arrow" aria-hidden="true">…</svg> </th> <th scope="col">Status</th> </tr> </thead> <tbody> <tr> <td>#1071</td> <td>22 Oct 2026</td> <td>$9.90</td> <td>Processing</td> </tr> … </tbody></table>What is wrong3 points
- The Date and Total headers take the click themselves, so they are read as column headers, not buttons, and nothing says they can be pressed. Tab never reaches them either, a failure covered in div buttons that can't be reached with Tab.
- The arrow is an icon hidden from screen readers, shown and turned by classes the browser knows nothing about, so the sort is shown only to the eye.
- No header has
aria-sort, so neither the sorted column nor its direction is ever said.
<table class="orders"> <caption> Your orders <span class="visually-hidden">, column headers with buttons are sortable</span> </caption> <thead> <tr> <th scope="col">Order</th> <th scope="col" aria-sort="descending"> <button type="button" data-key="date"> Date <svg class="arrow" aria-hidden="true">…</svg> </button> </th> <th scope="col"> <button type="button" data-key="total"> Total <svg class="arrow" aria-hidden="true">…</svg> </button> </th> <th scope="col">Status</th> </tr> </thead> <tbody> <tr> <td>#1071</td> <td>22 Oct 2026</td> <td>$9.90</td> <td>Processing</td> </tr> … </tbody></table>How it is fixed4 steps
- Put each sortable header's text in a
button, named by the column alone. Tab reaches it, Enter and Space press it, and it is read as "Date, button". - Set
aria-sort="descending"on the Date header as the page loads, and move it on every press: take it off every header, then set it, ascending or descending, on the one pressed. A screen reader now says that the column is sorted, and which way, with the header. - Draw the arrow from
aria-sortinstead of a class, so what is drawn and what is read can't disagree, and keeparia-hiddenon the icon so it adds nothing to the button's name. - Add a visually hidden note to the caption, ", column headers with buttons are sortable", so the table says once that its headers sort, without adding words to every header.
This fixes both Befores and passes 4.1.2. Order and Status have no button, so they take no aria-sort either.
Why this fix: Style from the state, not a class
Keep "sort" out of the header's name. A header's name is read with the cells under it, so a button named "Sort by date" makes a screen reader read "Sort by date" before dates as the person moves down the column. Some libraries put a label such as "Date: Activate to invert sorting" on the header cell itself, with the same effect. The note in the caption says it once.
One column at a time. ARIA asks authors to apply aria-sort to only one header of a table at a time, so leave it off the columns that aren't sorted: none is what no attribute already means. A table sorted by status and then by date can mark status, and say the second key in the caption. aria-sort="other" is for a sort that is neither ascending nor descending, such as an order the customer chose.
Hearing the new order at once. Pressing the button leaves focus on it, and a screen reader may not read the changed aria-sort until the person moves off the header and back. When people need to hear it right away, also write a short message, such as "Sorted by Total, highest first", into a polite status region that is already on the page, as in status messages that are shown but never announced. Word it as the header says it, and don't let it replace aria-sort, which is what a person meets on returning to the table.
Sorting by links. A table sorted on the server, with links such as ?sort=total that reload the page, has links in its headers rather than buttons. The page that loads still sets aria-sort on the sorted column, and its title or heading can say the order.
The arrow's shape carries the direction. Draw one arrow pointing the way the rows run, not two carets with the active one in another colour: when the direction is told by colour alone, someone who can't see that colour can't tell up from down, which fails 1.4.1 Use of Color. The design ideas below show ways to draw it that pass.
The arrow in a Windows contrast theme. Draw the arrow with currentColor or as text, so it keeps the header's colour when the theme replaces colours, and not as a background image, which the theme drops when it isn't a file and leaves in its own colours when it is.
The headers themselves are a separate failure, covered in data tables with headers that are only bold text, and the caption that names the table in tables with a title but no caption. All three belong in the same table component.
Design ideas
A sort arrow has more than one look.
Five more ways to draw the headers of the same orders table. Press them and Tab to them: four say the order by shape or in words, and one only by colour.
In the design file
- Draw each sortable header at rest, sorted each way, on hover and on focus.
- Show the direction by the arrow’s shape, pointing the way the rows run, never by colour alone.
- Write which columns sort, and which one the table opens sorted by, and which way.
- Keep each header’s words as the column’s name, and draw a line such as “Newest first” for the eye only.
Passes
Order #1071 22 Oct $9.90 #1063 18 Oct $142.00 Up-down arrow until sorted Every column that sorts carries a grey up-down arrow, 4.83:1 on white, so people can see which ones sort before they press. The sorted one swaps it for a single arrow pointing the way the rows run.Order #1071 22 Oct $9.90 #1063 18 Oct $142.00 Sorted header in a soft pill A tinted pill marks the sorted column, as a chosen filter chip does, with the arrow inside it for the direction. The #1d4ed8 text keeps 5.80:1 on the #e8effd tint.Order #1071 22 Oct $9.90 #1063 18 Oct $142.00 The order in words A small grey line under the sorted header, “Newest first”, says what an arrow leaves to a guess: does down mean newest or oldest? Write the words for each column and both directions.- A Sort by menu above the table Shops often put the order in a menu above the list instead. “Sort by: Newest first” names the order in words and works alike with a mouse, a keyboard and a phone. The headers stay plain, so nothing suggests that they sort.
Rule of thumb: Use native HTML first
Fails
Order #1071 22 Oct $9.90 #1063 18 Oct $142.00 Two carets, the active one in colourWCAG 1.4.1 Both carets are always there, and the one that gives the direction turns #2563eb blue beside a #6b7280 grey: 1.06:1 between them, so only the hue tells them apart, and someone who can’t see the blue can’t tell up from down. Hide the other caret on the sorted column, or draw a single arrow.
Verify the fix
5 checks, no mouse.
Test with: Screen reader, Keyboard
Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the table component changes.
Limits
This is a starting pattern, not a guarantee for every table. renderRows stands in for your own code that redraws the body. Screen readers word aria-sort differently, and one may read it only when focus enters the header cell, not on every press. When sorting also filters the rows, or a search narrows them, the number of rows shown is a separate failure, covered in search and filter results that update without a word.
This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.
EaseWeb service
Fixed it? Keep it from coming back.
I turn guides like this one into rules your coding agents follow and checks your CI runs. Then I retest by hand with a keyboard and screen readers, periodically.