Sortable Table Columns

An accessible sortable table is not necessarily the same as a usable sortable table.

WCAG offers some guidance on what to do and ARIA provides attributes to expose the states to screen readers. But I have found that bringing it all together can frustrate teams, particularly when factoring in browser support, screen reader heuristics, and lack of experience in testing. Hopefully this post can make some of that a bit less daunting, if only by giving you some ideas to borrow or avoid.

If you are here to read about sortable rows, you are on our own.


Most basically you need to use ARIA’s aria-sort property. Put it on the <th>.

Items are sorted in ascending order by this column.
Items are sorted in descending order by this column.
aria-sort="none" (default)
There is no defined sort applied to the column.
A sort algorithm other than ascending or descending has been applied.

If your column is not sorted, instead of using aria-sort="none" remove the attribute instead. You should not use aria-sort on more than one column header at a time. I don’t discuss aria-sort="other" in this post because I have rarely run into it in the wild.

Let The User Know This Thing Has Sorted

I am leading with the screen reader impact because it requires an example, and that example demonstrates the other methods I use to indicate the sort state. No sorting is performed on the tables in the examples; the examples only demonstrate the sorting controls.

Generally aria-sort will convey sort state for screen readers as they navigate the column headers.

See the Pen Basic Sortable Table by Adrian Roselli (@aardrian) on CodePen.

Visit the Basic Sortable Table pen, or Basic Sortable Table in debug mode so you can test without the Codepen wrappers.

Screen Reader Announcement

When activating a sort button, not all screen readers tell the user that anything has happened. JAWS, for example, will tell me immediately that a thing has sorted and how it sorted. NVDA and TalkBack say nothing. VoiceOver on iOS announces the column name and, if you do not leave the sort button before re-activating it, the last sort state (without updating).

From testing, that lack of announcement can be problematic for users who are not regular users of the table. This leaves you with a decision about whether you spackle over that gap or not. In my example, I have an ARIA live region that is turned off by default. You can toggle it between off, assertive, and polite, and compare the behavior across browser and screen reader pairings. For VoiceOver on macOS, you will need to restart VoiceOver in order for it to recognize the live region changes.

If you know all your users come in on JAWS then you can skip a live region. Otherwise you have to make some decisions about how verbose you want to be as that button is pressed. Generally you do not want to try to detect the screen reader, partly because it is not effective and partly because you do not want to alienate your users.

Alternatively, you could integrate this as a feature into your project. Perhaps you have signed-in users and provide a method for them to store application or site preferences. There may be a case for allowing them to manage the live region as a site-wide preference.

Back to this example, the live region clears itself after a second, otherwise sorting another column in the same direction won’t result in a change to the text (which means there would be no announcement).

I include the live region toggle in all the other examples in this post so you can test it alongside the other features.

function toggleSort(btnID, colID, colNum, regionID) {
  var liveRegion = document.getElementById(regionID);
  if (currSort == "descending") {
    liveRegion.innerHTML = "sorted up";
  } else {
    liveRegion.innerHTML = "sorted down";
  setTimeout(function () {
    liveRegion.innerHTML = "";
  }, 1000);

Warning: As you can see in the function, the text that gets added to the live region is embedded in the function itself. It is unlikely to auto translate. When you build your own function for production use, ensure you pull the text from somewhere that will be translated when the user runs the page through automated translation tools. If you use human localization, then make sure you include these strings in your workflow.

Sort Arrows

By default the arrows are each a chevron — a pair of lines pointing up, and a pair pointing down (using stroke on the SVG). When the column has been sorted the lines are replaced with a triangle (using fill on the SVG). Doing this means you need not rely on changing colors to indicate the sort direction, which itself could put you at risk of failing 1.4.1 Use of Color.

The Author column header with hollow arrows, then a filled downward arrow, and then a filled upward arrow.
One column header unsorted, sorted ascending, and sorted descending.

Column Background

For longer tables, unless you use fixed table headers, it can be easy to lose track of which column is sorted once the column headers scroll out of view.

In my (not production-ready) code, I walk through each cell in the column and give it a class so it can accept a background color.

function toggleSort(btnID, colID, colNum, regionID) {
  var sortedTDs = document.querySelectorAll(
    "td:nth-child(" + colNum + "), *[role=cell]:nth-child(" + colNum + ")"
  for (var i = 0; i < sortedTDs.length; i++) {
td.sorted {
  background-color: rgba(255, 255, 0, 0.15);

Let The User Know This Thing Sorts

The most common way of letting a user know that a column sorts is by including an up/down arrow alongside the column header text. Generally one of those arrows visually changes in some way to make it clear in which direction the column has been sorted.


In my examples I use two SVG arrows embedded elsewhere in the page and referenced with a <use> element so I am not repeating the code in every column header. The SVGs get focusable="false" for that rare case where IE11 rolls in and treats each SVG as a tab-stop. It also gets aria-hidden="true" to prevent VoiceOver from announcing its presence (as a group).

<th […]>
  <button […]>
    <svg […] focusable="false" class="sort asc" aria-hidden="true">
      <use […]></use>
    <svg […] focusable="false" class="sort des" aria-hidden="true">
      <use […]></use>

My <use> approach does not work in Internet Explorer 11.


CSS grid handles the layout for me, with minmax(2em, max-content) on the grid area for the text label. I do this so it does not take up half the width of the available space (which would otherwise push the arrows to midway along the width of the button). Using empty CSS generated content works to fill up the remaining space.

th > button {
  display: grid;
  grid-template-columns: minmax(2em, max-content) .65em auto;
  grid-template-areas: "t a x" "t d x";

th > button > span {
  grid-area: t;
  padding-right: .5em;

th > button > .asc {
  grid-area: a;
  align-self: center;

th > button > .des {
  grid-area: d;
  align-self: center;

th > button::after {
  content: "";
  grid-area: x;

IE11 and Safari on iOS 13.7 and below do not honor this layout (see Can I Use for grid-template-areas).

Windows High Contrast Mode

The SVG stroke and fill colors in my example will not change for Windows users who switch to High Contrast Mode (WHCM). I use the forced-colors feature query to set their colors to the CSS4 system color keyword ButtonText, which is the same color as the button. Unlike native buttons under WHCM (which do not change text color on hover), I assign CanvasText to the hover color for the SVG (the color of the page text).

@media screen and (-ms-high-contrast: active),
  screen and (forced-colors: active) {
   :root {
    --col-header-color: ButtonText;
    --col-header-hover-color: WindowText;
    --col-header-hover-color: CanvasText;

th > button svg.sort {
  fill: transparent;
  stroke: var(--col-header-color);
  max-width: .65em;
  max-height: 1.2em;

[aria-sort="ascending"] > button svg.asc {
  stroke: var(--col-header-color);
  fill: var(--col-header-color);

[aria-sort="descending"] > button svg.des {
  stroke: var(--col-header-color);
  fill: var(--col-header-color);

th:focus > button svg.sort, th:hover > button svg.sort, th:focus-within > button svg.sort {
  stroke: var(--col-header-hover-color);

[aria-sort="ascending"] > button:focus svg.asc,
[aria-sort="ascending"] > button:hover svg.asc,
[aria-sort="descending"] > button:focus svg.des,
[aria-sort="descending"] > button:hover svg.des {
  stroke: var(--col-header-hover-color);
  fill: var(--col-header-hover-color);

You may see I am using :focus-within in my example. Remember that adding :focus-visible (a different selector) to a selector hides the entire selector for older browsers. The same is true for any selector that a browser does not recognize, so my code kills IE11 support.

Screen Readers

The arrow approach does not work for blind screen reader users (remember, not all screen reader users are blind). Since aria-sort should only appear on columns that are sorted, there is no ARIA attribute hanging out on the other columns to act as a hint. You as the developer have to convey to users that the button in the header means the column can be sorted — unless you find your users expect it and do not want the extra noise.

I have worked with plenty of clients with users in plenty of scenarios — users in closed systems, open systems, single-use interactions, daily interactions, low screen reader skill, high screen reader skill, switching screen readers, and so on. Sitting with users across these and many more permutations has told me there is no one ideal way to handle this.

Instead I show five different techniques, each of which I have tested and each of which has performed reasonably well for their context but also not so well in other contexts. The value to you is that you can run tests with each of these, while also making some of your own.

You will see I do not use aria-label in any of these. Experience doing localization work has taught me the attribute is too often missed when handling strings for translation. For automated translation, while support has gotten better, you cannot count on aria-label being auto translated. Generally I follow the naming process I outline in my post My Priority of Methods for Labeling a Control.

<caption> Sort Hint

This is the most straightforward option. Embed the instructions in the <caption> and all users will be able to see and/or hear it. It gets announced only once, when entering the table.

See the Pen Sortable Table: caption Sort Hint by Adrian Roselli (@aardrian) on CodePen.

The editable pen, or debug mode.

The Year column announces as:

aria-describedby with Hidden <caption> Sort Hint

I have worked with clients who really dislike seeing a <caption>. You can visually hide it, but then non-screen reader users will never know it is there. That may be fine since the arrows take up that job.

In this pen I visually hide the instructions part of the <caption>, but also take it a bit further by associating the visually-hidden text with each button using aria-describedby. It only announces after the button name, role, and operation instructions, so it is easy to skip. While many users in testing found it verbose, a few appreciated the reminder and preferred having the association.

You need not split the <caption>, and likely would not in a real project, but I wanted to show a couple different code techniques in one. So yeah, this example is a bit contrived though I have tested with it.

See the Pen Sortable Table: aria-describedby with Hidden caption Sort Hint by Adrian Roselli (@aardrian) on CodePen.

The editable pen, or debug mode.

The Year column announces as:

aria-describedby Sort Hint

Instead of using the <caption>, I have a <span> hanging out in the page using display: none. While this hides the content from screen readers as well as display, it is available for use by the accessibility APIs, though you should always test it. I associate the text with the buttons using aria-describedby.

See the Pen Basic Sortable Table by Adrian Roselli (@aardrian) on CodePen.

The editable pen, or debug mode.

The Year column announces as:

aria-labelledby Sort Hint

This is the previous example, except here I use aria-labelledby to make it part of the button’s accessible name (instead of aria-describedby). It is important to know this will override the text already in the button, so I reference the button’s own id attribute and then the <span>’s id to ensure I have a compound name (button text plus sort hint). This also helps ensure I do not accidentally create a 2.5.3 Label in Name violation.

The issue with this approach is that now the column header is named “Column Name Sort” (or similar), which means as the screen reader user navigates the table, whenever entering a cell in this column they will hear the column name as “Column Name Sort”. That makes for a more verbose experience.

See the Pen Sortable Table: aria-labelledby Sort Hint by Adrian Roselli (@aardrian) on CodePen.

The editable pen, or debug mode.

The Year column announces as:

aria-roledescription Sort Hint

This can be the best or worst choice, depending on the technology and language of your users. First, not all browser and screen reader combinations support it. Second, aria-roledescription does not auto-translate.

However, if you knowyour users’ native language is the same as the page, they will never come on TalkBack, and you will always use a native HTML button, then this could work for you.

See the Pen Sortable Table: aria-roledescription Sort Hint by Adrian Roselli (@aardrian) on CodePen.

The editable pen, or debug mode.

The Year column announces as:

Related: Mad Libs

Remember my post Sortable Table Column Mad Libs? Yeah. I have embedded the pen from that post as it may be helpful here. Be sure to scroll down in the pen to see the assorted plain text announcements from screen readers.

Try Sortable Column Mad Libs in debug mode so you have more room to see it all.

See the Pen Sortable Column Mad Libs by Adrian Roselli (@aardrian) on CodePen.


If you made it to the end of this 3,150 word tome and were hoping for a block of code you could cut-&-paste into your projects, I am sorry to disappoint you. However, I hope I have given you enough information and examples that you can set up your own tests with users to identify the pattern that best fits your needs and their expectations.

As always, the data I report about browser support is subject to change. You should expect it to change. At the very least the information here provides a point in time baseline.



It surprises me that aria-sort isn’t specced like aria-expanded, where its presence with a missing/default value indicates that the column is sortable, but not sorted right now. That could mean a straightforward implementation of adding it to every column that can be meaningfully sorted — especially for cases where you can sort by multiple dimensions at a time (weird, I know, but I’ve used software where that was surprisingly useful).

In response to Taylor Hunt. Reply

Originally I agreed. But then I found that the presence of a button and an unsorted announcement made for verbose column headers. In many cases instructions or context are all you need, and by only announcing the sorted column it means you are not adding to the cognitive load of each column header.

There may be cases where the opposite is true.


Just wanted to thank you for this post. This helps me out a ton in order to implement this feature on my own data table component. I very much appreciate the work you put into explaining this. Keep up the good content!

Eric Zieger; . Permalink

If you are looking for more challenges you could try to include filtering in addition to sorting. It can get really verbose really fast. I placed filtering controls (text input with a label) to a separate data row right after the heading row with sorting controls. This avoids filters being announced at every column. Of course you also need some tweaks to the live region to announce both sorting and filtering.

Would love to see your approach. :)

In response to Sampo. Reply

Sampo, so far I have always been able to build filtering outside the table. I work to include only data in a table, exempting column/row headers and maybe row-specific selection controls. It makes for a less verbose table and generally eases keyboard and screen reader navigation. On the other hand, it often means the user needs to have at least a little experience with the columns / structure of the table in order to grok the filtering options.

Leave a Comment or Response

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>