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.

Basics

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

aria-sort="ascending"
Items are sorted in ascending order by this column.
aria-sort="descending"
Items are sorted in descending order by this column.
aria-sort="none" (default)
There is no defined sort applied to the column.
aria-sort="other"
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++) {
    sortedTDs[i].classList.add("sorted");
  }
  […]
}
td.sorted {
  background-color: rgba(255, 255, 0, 0.15);
}

26 June 2021: Column Background via <col>

In the 12 June update to my post Fixed Table Headers I mention the overall table re-write in Chromium. That same Chromium update introduced more useful support for <col>.

Now that Chromium has caught up to the competition on this feature, with some minor changes to my code you can style the <col> element for a column instead of looping through every cell in the table to find ones in that column.

In the toggleSort(), replace var sortedTDs and its associated for loop with:

  var sortedCol = document.querySelector(
    "col:nth-child(" + colNum + ")"
  );
  sortedCol.classList.add("sorted");

In the CSS, remove the td from the td.sort selector. Change the <th> background color to an opaque color (unless you want the column’s yellow to show through, after assuring contrast is good).

This works in Firefox (for at least a couple years, but if you know which version please comment), Safari (since at least v14.0.2, but versions welcome), and now Chrome 91 and Edge 91.

See the Pen Basic Sortable Table: with `col` support by Adrian Roselli (@aardrian) on CodePen.

30 June 2024: CSS-only Column Background

During a re-review of this post I tried to dump that script that adds the class to the <col>. With support for :has() in modern browsers, it seemed time to rely on the programmatic state of the column header versus a class (even if it’s in the same function that changes the programmatic state).

I already know the column combinator (||) has poor support, but if it did have good support then Amelia Bellamy-Royds proposed col:has(|| th[aria-sort]).

Amelia left that on an issue Clar Fon had filed to suggest the CSS Working Group could come up with a column selector that allowed me to do something more elegant than this (which requires one selector per column):

colgroup:has(+ thead th[aria-sort]:nth-child(1)) col:nth-child(1) {
  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.

SVGs

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 […]>
    <span>Author</span>
    <svg […] focusable="false" class="sort asc" aria-hidden="true">
      <use […]></use>
    </svg>
    <svg […] focusable="false" class="sort des" aria-hidden="true">
      <use […]></use>
    </svg>
  </button>
</th>

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

Layout

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.

Wrap-up

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.

Multi-Column Sortable Table Experiment

Added 13 June 2021. I have more than once fielded questions about how to handle a table that allows more than one column to sort. Given ARIA 1.1’s statement that only one column should sort at a time versus business cases where two, sometimes more, columns need be sortable in a table, I finally got around to prototyping an example.

Go read Multi-Column Sortable Table Experiment and see if you find it helpful.

15 Comments

Reply

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.

Reply

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
Reply

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.

Reply

Could you implement something similar to a table summery (I know it’s now depreicated) so maybe within the table caption, include hidden text (so only screen reader users will hear it), “buttons in the column headers provide sorting functionality .”

In response to Stacy. Reply

Give it a shot? I try to avoid deprecated features since support for them could end at any time. Fork one of my samples and run with it. If your audience has the support and it tests well with them, go for it? And, of course, be prepared to pivot when support changes.

Reply

Hello! Why doesn’t the sort work on any of your table examples?

Crystal G O'Mara; . Permalink
In response to Crystal G O'Mara. Reply

To focus on the controls, not an arbitrary sorting script that can confuse the issue. Especially since most teams already have a sorting script. I note it in the post:

No sorting is performed on the tables in the examples; the examples only demonstrate the sorting controls.

Reply

Hey, thanks or your great explanation on sorting tables for sr. I am wondering if sr users typically do not use the shortcuts for buttons (I mean, when navigating between headings, landmarks, formfields, buttons). Because then there are buttons which I guess are not really descriptive when hearing them without context? In your example there are not that many buttons anyways, but I have a site with many tables on one side and not feeling sure if this would confuse sr users. Happy about your response and thoughts on this.

In response to Julie. Reply

Julie, I missed this comment while on the road. Many screen reader users indeed use shortcut keys to jump to buttons, regions, headings, lists, tables, and so on. Not all of them, of course, partly because skill level among screen reader users mimics skill level among all users. When navigating by buttons, context may be provided by the screen reader. For example, using NVDA / Firefox, using B announces the column number, the button name, and that it’s a button. For a screen reader user that would be enough context to understand they are in a table.

Reply

I noticed in the code for the svg sort icons the role is set to “image”, where the correct value would be “img” (because spell everything out except this one I guess).

I suppose it doesn’t actually matter in the end as they are embedded in svg with aria-hidden. Unless I’m missing a use case?

Joppe Kroon; . Permalink
In response to Joppe Kroon. Reply

Yeah, one of my favorite typos. Kind of like <lable>. Anyway, not exposed to users because that role is set on the sprite and that entire SVG block is display: none. Where the sprite is called, in the button, you are correct that aria-hidden would have mooted it had I used the incorrect role there as well.

Reply

I’ve only begun to look at this but seeing as you’ve studied this very complex topic a great deal, I wondered whether you can tell me how your various approaches work for the user when browsing through the table’s data rows. It’s great, when reading through the column headings, to hear “column 3 header company name sorted ascending click to sort descending” and, after clicking/pressing the button, being informed in some manner of the update. It’s another thing, on every line after that, to hear “column 3 company name sorted ascending click to sort descending ABC Pet Supply” when one want to hear only “column 3 company name ABC Pet Supply”.

In my current efforts to work through this (and I’m using only NVDA, with Chrome, so far), it was even worse when I was using Unicode up-arrow, down-arrow, and up-down arrow characters, because the reader kept adding “up-arrow” or “down-arrow” or “up-and-down arrow” on top of it, even when reading the data rows! But since we’re using Bootstrap, I switched to Bootstrap’s custom characters for these, which NVDA doesn’t know how to read, so at least I was able to eliminate that little bit of auditory overload.

Jason Jehosephat; . Permalink
In response to Jason Jehosephat. Reply

Jason, each of the patterns includes how a cell announces when moving into it from an adjacent cell on the same row:

macOS 11.1 Big Sur / VoiceOver / Safari
Year, 1021, column 3 of 6
JAWS 2020 / Chrome 89
Year 1021 column 3
JAWS 2020 / IE11
Year 1021 column 3
NVDA 2020.4 / Firefox 87
Year column 3 1021
iPadOS 13.4.1 / VoiceOver / Safari
Year, 1021, column 3
Android 11 / TalkBack / Chrome 89
1021 Year
Android 11 / TalkBack / Firefox 87
1021

Essentially, the screen reader announces the column header, text in the cell, and the cell position. There is no extra content in the column header to pollute this because the aria-sort handles the programmatic side and the aria-hidden SVG arrows handle the visual side.

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>