Functions to Add ARIA to Tables and Lists

When I presented my talk CSS Display Properties versus HTML Semantics at WordCamp Buffalo, someone in the crowd asked if I could take all I demonstrated and make it into a plug-in. I said no.

Generally when I write and present things, I provide techniques, ideas, examples, and experience to address issues. When I write copy-paste-ready code, with a few older exceptions, I write generic code not tied to a framework nor library.

I am not writing a WordPress plug-in. I have, however, written a couple vanilla JavaScript functions that you can drop into your site. They might benefit from some optimizations, and how you call the functions is up to you (and your chosen platform).

I also chose to address only the two most common cases that I see of browsers breaking semantics due to CSS — tables and lists. As I note elsewhere, the ARIA you add will not save you from display: contents.

If / when the browsers ever fix their bugs, then you should purge these functions from your projects. So, you know, put that it in your backlog I guess.

Tables

The function walks through each <table> on a page and adds the appropriate table role and roles for all its standard descendant elements. It also identifies any row headers by the scope attribute (so make sure you use them correctly).

The final two example tables in the embedded Codepen come from the W3C examples for accessible complex tables.

Embedded Example

If the embed does not work, you can visit it directly.

See the Pen Table Automated ARIA Role Assignment by Adrian Roselli (@aardrian) on CodePen.

JavaScript Function

The function, ready for you to excoriate in the comments or copy into your own project:

function AddTableARIA() {
  try {
    var allTables = document.querySelectorAll('table');
    for (var i = 0; i < allTables.length; i++) {
      allTables[i].setAttribute('role','table');
    }
    var allCaptions = document.querySelectorAll('caption');
    for (var i = 0; i < allCaptions.length; i++) {
      allCaptions[i].setAttribute('role','caption');
    }
    var allRowGroups = document.querySelectorAll('thead, tbody, tfoot');
    for (var i = 0; i < allRowGroups.length; i++) {
      allRowGroups[i].setAttribute('role','rowgroup');
    }
    var allRows = document.querySelectorAll('tr');
    for (var i = 0; i < allRows.length; i++) {
      allRows[i].setAttribute('role','row');
    }
    var allCells = document.querySelectorAll('td');
    for (var i = 0; i < allCells.length; i++) {
      allCells[i].setAttribute('role','cell');
    }
    var allHeaders = document.querySelectorAll('th');
    for (var i = 0; i < allHeaders.length; i++) {
      allHeaders[i].setAttribute('role','columnheader');
    }
    // this accounts for scoped row headers
    var allRowHeaders = document.querySelectorAll('th[scope=row]');
    for (var i = 0; i < allRowHeaders.length; i++) {
      allRowHeaders[i].setAttribute('role','rowheader');
    }
  } catch (e) {
    console.log("AddTableARIA(): " + e);
  }
}

AddTableARIA();

Update

ARIA 1.2, still in draft as of this writing, has a caption role that maps to the native <caption> element. The function has been updated to include it.

Lists

The function walks through each <ol>, <ul>, and <dl> on a page and adds the appropriate list role and roles for all the required descendant elements.

Because ARIA does not have roles that differentiate between ordered and unordered lists, let alone description lists, avoid using this function unless you know the semantics of your list are broken. Even then only apply it to the list type affected. Note that for <dt> and <dd> I have two roles, in a bit of progressive enhancement.

Embedded Example

If the embed does not work, you can visit it directly.

See the Pen List Automated ARIA Role Assignment by Adrian Roselli (@aardrian) on CodePen.

JavaScript Function

The function, ready for you to eviscerate in the comments or copy into your own project:

function AddListARIA() {
  try {
    var allLists = document.querySelectorAll("ol, ul");
    for (var i = 0; i < allLists.length; i++) {
      allLists[i].setAttribute("role", "list");
    }
    var allListItems = document.querySelectorAll("li");
    for (var i = 0; i < allListItems.length; i++) {
      allListItems[i].setAttribute("role", "listitem");
    }
    var allDefLists = document.querySelectorAll("dl");
    for (var i = 0; i < allDefLists.length; i++) {
      allDefLists[i].setAttribute("role", "associationlist list");
    }
    var allDefTerms = document.querySelectorAll("dt");
    for (var i = 0; i < allDefTerms.length; i++) {
      allDefTerms[i].setAttribute("role", "associationlistitemkey listitem");
    }
    var allDefItems = document.querySelectorAll("dd");
    for (var i = 0; i < allDefItems.length; i++) {
      allDefItems[i].setAttribute("role", "associationlistitemvalue listitem");
    }
  } catch (e) {
    console.log("AddListARIA(): " + e);
  }
}

AddListARIA();

Update

ARIA 1.2, still in draft as of this writing, has an associationlist role that maps to the native <dl> element. In addition, it has an associationlistitemkey role mapping to <dt> and associationlistitemvalue role mapping to <dd>. The function has been updated to include these.

Wrap-up

If you end up converting this to the framework du jour, please drop a link to your repo hub in the comments.

Update: May 18, 2018

I have had the opportunity to test this function against a new technique for making tables responsive. Lea Verou has demonstrated a way to use text shadows to avoid duplicating content in her post Responsive tables, revisited. My opinion on this approach notwithstanding, it renders the tables unusable in a screen reader.

Each video uses NVDA 2018.1.1 and Firefox 59.0.3. The first video demonstrates the table in a viewport wide enough to allow navigation via NVDA table commands (Ctrl + Alt + ///). The second video demonstrates how Firefox no longer treats it as a table when the responsive styles kick in and table navigation commands no longer work.

I took the table ARIA function from above, pasted it into the JavaScript block of the Dabblet editor for the example from her post, and ran through it again using NVDA. This time I was able to interact with the table using the NVDA table navigation commands. If you just listen to the video below, you will hear that it follows the same navigation path as the first video above.

With the addition of the ARIA, Firefox retains the table semantics and I am able to navigate the table as a table in NVDA.

Update: July 19, 2018

I updated the function for lists to include the roles term for <dt> and definition for <dd>. Support is still meh, which is why I originally used listitem to be safe.

ARIA allows a space-separated list of roles, with the first supported one applied. I avoided this approach because of some bugs in screen readers with multiple roles, but three years on my testing suggests that for the majority of recent releases it is safe.

If you encounter troubles with this progressive enhancement, just yank one or the other role from each until things work.

Update: January 13, 2019

A Twitter thread kicked off a discussion about how Safari hands list information to VoiceOver based on how list item styles are used. Scott O'Hara did a great job capturing the conversation and the concerns about throwing ARIA at it in his post "Fixing" Lists.

You can use my functions above to try to brute-force VoiceOver to announce lists, but for VoiceOver users who already have experience with your site maybe don't do that. Look to the CSS solutions that Scott cites first, then maybe consider ARIA. James Craig has already filed an issue with Safari to update its heuristics.

I think Roger summed up the current situation nicely:

Update: January 22, 2020

The ARIA 1.2 draft includes roles for <caption> as caption, <dl> as associationlist, <dt> as associationlistitemkey, and <dd> as associationlistitemvalue. The functions, examples, and descriptions above have been updated even if the spec and browser support are not complete.

Update: February 19, 2020

Big progress. Chrome 80 no longer drops semantics for HTML tables when the display properties flex, grid, inline-block, or contents are used. The new Edge (ChromiEdge) follows suit. Firefox still dumps table semantics for only display: contents. Safari dumps table semantics for everything.

Update: September 29, 2020

Léonie Watson has just posted How screen readers navigate data tables where she walks through a sample table to get some information, explaining each step, the keyboard commands, and the output. She also links to a video demonstration, which I have embedded below.

Watch How screen readers navigate data tables at YouTube.

Update: August 24, 2023

If you came here from the Kevin Powell responsive table tutorial on YouTube, I have some additional links about accessible tables. If you have no idea what I am talking about, here is the video (links follow it):

Other posts on my site that go into accessibility bits related to tables:

Update: 7 October 2023

Copied from It’s Mid-2022 and Browsers (Mostly Safari) Still Break Accessibility via Display Properties.

Very good progress in Safari 17. Tables and description lists are no longer broken when display properties are applied. Buttons with display: contents, however, are still inoperable by keyboard users and problematic for VO users (and I confirmed is also the case in Safari TP 180).

Meanwhile, the heading issue I reported for Safari 17 on iPadOS (261978 - AX: Headings with `display: contents` cannot be navigated) has been marked as a VoiceOver issue with no insight when it will be fixed. But marking it a VoiceOver issue means Safari can claim to have no bug so yay?

Apple seems reasonably confident it has finally fixed its historically years-lagging support (despite prior claims), and so has been doing the rounds suddenly arguing all the other browsers and specs need to fix display: contents issues while using its own claims of (abruptly and questionably) better support to bolster them:

I also filed a PR with Can I Use to amend the one filed by Apple three weeks ago (I was unable to review owing to travel and this is not my job slash no one is paying me).

With Safari almost there on basic support and Apple now pushing for the specs and browsers to agree, after sitting it out for a few years, I am excited that the end is in sight. Which I expect before WCAG 3.

7 Comments

Reply

Hi Adrian, I appreciate you providing these helper functions. I want to ask the reason for adding redundancy to existing tabular and list markup. These elements already have implicit table and list roles that do not need to be explicitly set using ARIA. Thoughts? What would be really helpful is determining a way to fix mock tables and lists that use DIVs and other formatting elements. That would be fun! Keep up the good work, Adrian!

Nick Beranek; . Permalink
In response to Nick Beranek. Reply

Nick, in my post Tables, CSS Display Properties, and ARIA I demonstrate how changing the display properties of tables causes browsers to blows away all the native semantics. Adding ARIA puts semantics back on the tables. You can see this in action in the videos in the first update above. This also applies to lists, but I have not written a post dedicated to lists.

As for using other elements in place of lists and tables, I encourage all developers not to do that. See more in my post Hey, It’s Still OK to Use Tables.

In response to Adrian Roselli. Reply

Hey, thanks for the reply. I’ll research the usage and implication of the display property and tables. I had no idea that the semantic information was stripped… I’ll have to check out what information MSAA Object Inspect is presented and test with NVDA. Oh, and we definitely advocate using tables for tabular data, but we still get devs go alternative routes so we have ways to fix that. Thanks, again!

Nick Beranek; . Permalink
In response to Nick Beranek. Reply

I suspect you will find the information is stripped by Edge and doesn’t make it to MSAA, so it never makes it NVDA. At the very least I spoke with the Edge team about this and the more dangerous impact of display: contents, which Edge does not yet support, but which I hope when they do they do it right.

Reply

Performance tweak: once you have allTables, you don’t have to search for the needles allRowGroups in the almighty haystack called DOM using
document.querySelectorAll('thead, tbody, tfoot'),
but inside the loop in
allTables[i].querySelectorAll('thead, tbody, tfoot').

This applies to allRows in allRowGroups, and allCells & allHeaders in allRows respectively.

Once you got allHeaders you might check if they match th[scope=row], no need to search again for th[scope=row] afterwards.

I’ve forked it in this pen.
I haven’t measured the difference, though.

In response to Gunnar Bittersmann. Reply

Gunnar, great points. If I have already walked the DOM, then why walk it again when I can just walk the sub-tree I already parsed.

Reply

Superb stuff. Thanks a ton!

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>