# Sortable tables that show the sort only as an arrow

Source: https://easeweb.dev/learn/sortable-tables
Topics: Tables and data > Sortable tables say which column is sorted, and which way (WCAG 4.1.2)
The fix: Put a button with the column's name in each sortable header, and set aria-sort on the th of the column the rows are sorted by, moving it on every sort. Draw the arrow from aria-sort and hide it from screen readers, and say once, in the caption, that the headers with buttons sort the table.
Test with: screen reader, keyboard
References: https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F15 https://www.w3.org/WAI/ARIA/apg/patterns/table/examples/sortable-table/ https://www.w3.org/TR/wai-aria-1.2/#aria-sort https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-sort

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.

## What happens

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.

## Why it fails

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".

## Who is affected

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.

## What automation and AI miss

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.

## Before: the header cell takes the click

```html
<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>
```

```css
.orders .arrow {
  visibility: hidden;
}
.orders .sorted-asc .arrow,
.orders .sorted-desc .arrow {
  visibility: visible;
}
.orders .sorted-asc .arrow {
  rotate: 180deg;
}
```

```js
for (const th of document.querySelectorAll('.orders th[data-key]')) {
  th.addEventListener('click', () => sortBy(th));
}

function sortBy(th) {
  const dir = th.classList.contains('sorted-desc') ? 'asc' : 'desc';
  for (const other of document.querySelectorAll('.orders th')) {
    other.classList.remove('sorted-asc', 'sorted-desc');
  }
  th.classList.add(`sorted-${dir}`);
  renderRows(th.dataset.key, dir);
}
```

- 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](/learn/native-buttons).
- 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.

## Before: a button in each header, but no aria-sort

```html
<table class="orders">
  <caption>Your orders</caption>
  <thead>
    <tr>
      <th scope="col">Order</th>
      <th scope="col" class="sorted-desc">
        <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>
```

```css
.orders .arrow {
  visibility: hidden;
}
.orders .sorted-asc .arrow,
.orders .sorted-desc .arrow {
  visibility: visible;
}
.orders .sorted-asc .arrow {
  rotate: 180deg;
}
```

```js
for (const button of document.querySelectorAll('.orders th button')) {
  button.addEventListener('click', () => sortBy(button));
}

function sortBy(button) {
  const th = button.closest('th');
  const dir = th.classList.contains('sorted-desc') ? 'asc' : 'desc';
  for (const other of document.querySelectorAll('.orders th')) {
    other.classList.remove('sorted-asc', 'sorted-desc');
  }
  th.classList.add(`sorted-${dir}`);
  renderRows(button.dataset.key, dir);
}
```

The button is right: Tab reaches it, Enter and Space press it, and it is read as "Date, button". The sort is where it still fails:

- Nothing says the rows are sorted by date, or that the newest comes first.
- Pressing Total moves the class and turns the arrow, and the screen reader still hears only "Total, button".
- Nothing says that the buttons sort the table.

## After: a button in each header, and aria-sort on the sorted one

```html
<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>
```

```css
.orders .arrow {
  visibility: hidden;
}
.orders [aria-sort] .arrow {
  visibility: visible;
}
.orders [aria-sort="ascending"] .arrow {
  rotate: 180deg;
}
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
```

```js
for (const button of document.querySelectorAll('.orders th button')) {
  button.addEventListener('click', () => sortBy(button));
}

function sortBy(button) {
  const th = button.closest('th');
  const dir = th.getAttribute('aria-sort') === 'descending' ? 'ascending' : 'descending';
  for (const other of document.querySelectorAll('.orders th')) {
    other.removeAttribute('aria-sort');
  }
  th.setAttribute('aria-sort', dir);
  renderRows(button.dataset.key, dir);
}
```

1. 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".
2. 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.
3. Draw the arrow from `aria-sort` instead of a class, so what is drawn and what is read can't disagree, and keep `aria-hidden` on the icon so it adds nothing to the button's name.
4. 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.

## Implementation decisions

**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](/learn/status-messages). 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](/learn/table-headers), and the caption that names the table in [tables with a title but no caption](/learn/table-captions). All three belong in the same table component.

## Verify the fix

1. In the code, find every header that sorts the table, and confirm its text is in a button named by the column alone, and that no header cell takes clicks itself.
2. Confirm that exactly one `th` has `aria-sort`, on the column the rows are sorted by, in the direction the arrow shows, and that it moves when you sort by another column.
3. With a screen reader, Tab to each sortable header: each is announced as a button with its column's name, and the sorted one as sorted, ascending or descending, as drawn.
4. Press Total with Enter, move off it and back: it is announced as sorted the way the rows now run, and Date is no longer announced as sorted.
5. Jump to the table with the screen reader's table key (T in NVDA and JAWS): the caption is read with its note that the headers sort.

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](/learn/search-results).

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
