Making Sortable Table Columns Accessible: The aria-sort Attribute for E-Commerce
A sortable table shows up in two common places on an e-commerce site: the “My Orders” order-history page (sort by date, order total, or status) and a product-comparison table (sort by price, rating, or a spec column). Both are usually built the same way — a clickable column header, a small arrow icon that flips or highlights to show the active sort column and direction, and a JavaScript click handler that re-orders the rows. Visually that’s a complete sort control. Programmatically, if the sort state only lives in an arrow icon’s CSS class, it doesn’t exist at all for anyone using a screen reader.
Quick answer: a sortable column header needs three things. The sort control itself needs to be a real, keyboard-operable <button>, not a <div> with a click handler (WCAG SC 2.1.1/4.1.2). The current sort column and direction need to be exposed with the aria-sort attribute — ascending, descending, none, or other — set on the <th> itself, not the button inside it (WCAG SC 1.3.1 Info and Relationships). And when a shopper triggers a re-sort, the fact that rows reordered needs to be announced, not just silently redrawn (WCAG SC 4.1.3 Status Messages).
This article is general accessibility information, not legal advice. Meeting WCAG 2.1 AA is a technical-practice standard, not a legal determination — consult qualified counsel for legal risk questions.
Why the arrow icon alone isn’t enough
The near-universal pattern is a tiny triangle or chevron glyph next to a column header’s text — pointing up for ascending, down for descending, sometimes just dimmed or hidden entirely when that column isn’t the active sort. A sighted user reads the icon’s direction and position at a glance. A screen reader user tabbing to that header hears whatever the button’s accessible name says, and if the only signal of “this column, this direction” is a CSS-styled icon with no text alternative, they hear nothing about sort state at all — just the column name, same as every other header.
This is a direct instance of WCAG SC 1.3.1 Info and Relationships (Level A): information conveyed visually — here, which column is sorted and in which direction — has to also be available programmatically, not just through position, shape, or color of an icon.
The fix: aria-sort, and where it actually goes
MDN’s reference states the attribute plainly: “The aria-sort attribute indicates if items in a table or grid are sorted in ascending or descending order.” It takes one of four values:
ascending— sorted in ascending order by this columndescending— sorted in descending order by this columnnone(the default) — no sort applied to this columnother— some sorting algorithm other than ascending/descending
MDN is explicit about where it belongs: aria-sort “should be set on the header cell element for the sorted column or row,” used on one column header at a time, and — a detail worth repeating because it’s easy to assume otherwise — “it does not have any impact on the actual sort order.” The attribute only announces state; the JavaScript that actually re-orders rows is a separate, unrelated piece of code that still has to be written.
The W3C ARIA Authoring Practices Guide (APG) Table pattern confirms the same placement in its own words: “If the table contains sortable columns or rows, aria-sort is set to an appropriate value on the header cell element for the sorted column or row.” That’s the detail most hand-rolled implementations get wrong — putting aria-sort on the <button> inside the header instead of the <th> that wraps it. A screen reader looks for the attribute on the header cell specifically; setting it on the button does nothing.
The sort button itself: APG’s own worked example
The APG’s Sortable Table example is a real, runnable reference implementation, and its markup answers most of the remaining questions directly. The sort control is “a button element” wrapping the column’s header text, nested inside the <th>:
<table class="sortable">
<caption>
Your Orders
<span class="sr-only"> (column headers with buttons are sortable).</span>
</caption>
<thead>
<tr>
<th>
<button type="button">
Order Date
<span aria-hidden="true"></span>
</button>
</th>
<th aria-sort="descending">
<button type="button">
Total
<span aria-hidden="true"></span>
</button>
</th>
<th class="no-sort">Status</th>
</tr>
</thead>
<tbody><!-- rows --></tbody>
</table>
Three details in that markup come straight from the example’s own documented reasoning, not improvised:
- The sort-direction icon is
aria-hidden="true". The example’s own attribute table states its purpose directly: it “removes the character entities used for sort icons from the accessibility tree to prevent them from being included in the accessible name of the sort buttons.” Without it, a screen reader can end up reading a stray glyph or Unicode arrow as part of the button’s name. aria-sortlives on the<th>, moves on click. Per the example: “Set on the currently sorted column. When the sorted column is changed, thearia-sortattribute is removed and set on the newly sorted column.” Only one header carries the attribute at any time.- Sort instructions go in the table
<caption>, once — not repeated on every button. This is the most useful, least obvious detail: “To help screen reader users understand the purpose of the buttons in the column headers, an off-screen description of the sort functionality of the buttons is appended to the caption text. The description is added to the caption instead of to each button to prevent repetitious verbosity that could interfere with understanding of the column titles.” A visually-hidden span inside the<caption>— “(column headers with buttons are sortable)” in the example — does this once for the whole table, instead of every single button’s accessible name saying “sortable” on top of its column label.
A non-sortable column (the Status column above) is just a plain <th> with no button and no aria-sort — not every column header in a comparison or order table needs to be sortable, and not pretending one is avoids a dead button that does nothing when activated.
Announce the re-sort — don’t let it happen silently
Clicking a sort button reorders every row in the table. A sighted shopper sees the new order immediately. A screen reader user whose focus stays on the button they just activated gets no indication anything happened unless the page tells them — the same gap this site has already covered for filtered results and paginated content under WCAG SC 4.1.3 Status Messages (Level AA). Rather than re-explain live-region mechanics here, the short version: a role="status" region that already exists in the page (not injected fresh at sort time) with text like “Sorted by Total, descending” gets announced without moving focus away from the sort button. See accessible-product-filtering-faceted-search.md’s results-count section for the fuller aria-live/role="status" treatment — the mechanism is identical, only the message content changes.
Two e-commerce patterns this applies to
Order-history (“My Orders”) tables. A shopper’s order list is a near-universal account-page feature — sortable by order date, total, or status. These tables are usually generated from the same component library as everything else in the account dashboard, which means the same <div>-based clickable-header shortcuts that show up elsewhere on a site show up here too, with the added problem that an order-history table often has no <th> elements at all, let alone aria-sort — a separate SC 1.3.1 failure this article’s header-button fix doesn’t address by itself. If column headers aren’t real <th> cells with scope="col" yet, that’s the first fix, before sorting is even in scope.
Product-comparison tables. This site’s product-comparison-tables-accessible-ecommerce.md already covers getting the base <th>/scope structure right for a side-by-side spec table. Sorting rows or columns by price or rating is an enhancement layered on top of that same table — everything in this article (the <button>-in-<th> pattern, aria-sort placement, the caption-based instruction) applies directly once the base table structure is already correct.
A quick manual test
- Tab to a column header. Can you reach it and hear something more specific than the bare column name — ideally, a cue that it’s a sort control? If it’s a
<div>with notabindex, it’s skipped by keyboard entirely. - Press Enter or Space on a focused sort button. A real
<button>responds to both and re-sorts. A styled<span>with only a mouse click handler usually responds to neither. - Check DevTools’ Accessibility pane (or a screen reader) on the currently-sorted column’s
<th>. It should exposearia-sort="ascending"or"descending"— notnone, and not absent entirely. - Turn on VoiceOver or NVDA, trigger a sort, and listen. You should hear something indicating the table re-sorted (a status announcement), not silence followed by different numbers.
FAQ
Can I put aria-sort directly on the <button> instead of the <th>?
No — both MDN and the APG example are explicit that it belongs on the header cell (<th>, role columnheader), not the interactive element inside it. A screen reader looking for sort state checks the header cell.
Do I need aria-sort="none" on every unsorted column?
No. none is the implicit default — MDN lists it as such. Only the currently-sorted column needs the attribute set explicitly, and it moves to a different column (rather than being added to every header) when the sort changes.
What if a column genuinely isn’t sortable — can I skip the button entirely?
Yes, and you should. The APG example itself includes exactly this case (an “Address” column with no button) — a plain <th> with text and no interactive control, rather than a button that does nothing when activated.
Get a human-reviewed audit, not just a scan
An automated scanner can confirm a <button> exists inside a table header. It can’t tell you whether aria-sort actually moves to the new column when a shopper clicks, whether the re-sort gets announced, or whether the sort-icon glyph leaked into the button’s accessible name — all of which require operating the table, not just parsing its markup. Quietramp’s audits pair an automated scan with a manual review that checks exactly this kind of interaction, delivered as a prioritized, developer-actionable PDF report. See a real sample report or check pricing — $890 one-time, $99/month for ongoing re-checks after fixes ship.
This article is general accessibility information, not legal advice. Meeting WCAG 2.1 AA does not resolve ADA or any other legal compliance question, and nothing here should be read as a compliance guarantee. Consult qualified counsel for legal risk questions.
This article was drafted with AI assistance and reviewed by a person for accuracy before publication.