Under-Engineered Responsive Tables

I have written a bunch about responsive tables. Maybe too much. I keep trying to give developers the information they need to make informed decisions — ARIA attributes, screen reader & browser pairing results, bugs, and so on. I have spread things out over years of posts. I have filed issues and given talks and evangelized in meet-ups and…

Perhaps that is why I have had little traction.

This post is meant to simplify that. It will reference previous work, but mostly I am just going to show you the bare minimum you need to make a WCAG-compliant responsive HTML table. I have tested this with users, along with the version I made that reflows the content, and this one performs better for all users.

Mostly I am expanding a tweet thread.

HTML

You need two lines of HTML:

<div role="region" aria-labelledby="Caption01" tabindex="0">
        <table>[…]</table>
</div>

The tabindex="0" satisfies WCAG SC 2.1.1 Keyboard (A), which allows a keyboard-only user to tab to the container (giving it focus) and scroll it using the keyboard.

Anything that receives focus must have an accessible name and role that can be programmatically determined in order to satisfy WCAG SC 4.1.2 Name, Role, Value (A). The aria-labelledby and role attributes provide those, assuming Caption01 is the value of the id attribute of the table’s <caption>. I use region as the role since it is a generic landmark.

Voice users may not know the accessible name to select the container, but they can at least still select it by asking to see all interactive elements on the page.

CSS

Then you need six lines of CSS:

[role="region"][aria-labelledby][tabindex] {
  overflow: auto;
}

[role="region"][aria-labelledby][tabindex]:focus {
  outline: .1em solid rgba(0,0,0,.1);
}

The overflow: auto satisfies WCAG SC 1.4.10 Reflow (AA) by preventing the entire page from having two axes of scrolling (big table, small viewport).

The outline covers WCAG SC 2.4.7 Focus Visible (AA), but to be safe on WCAG SC 1.4.11 Non-text Contrast (AA), use an outline color with a 3:1 contrast ratio. Using outline ensures it will be visible in Windows High Contrast Mode.

Note the CSS selector: [role="region"][aria-labelledby][tabindex]

This selector ensures the table will not be clipped unless the HTML is properly marked up to be accessible to keyboard and screen reader users. This approach is better than relying on a class or id as a selector since it helps enforce the needed HTML. You can tweak it to your needs, perhaps by using [tabindex="0"] instead of [tabindex] or [role] instead of [role="region"], but it partly depends on what you need to enforce.

Usability Bonus

You can offer a visual cue for vertical scrolling with shadows from Lea Verou or horizontal scrolling shadows from Chen Hui Jing. This can be particularly handy for users on browsers that hide scrollbars by default (probably do not over-compensate with custom scrollbars). If you use this approach with non-RTL languages, you may need to add language / direction selector to swap between them.

I put them to use in my post Fixed Table Headers (which is mostly a longer version of this post).

Example

A basic table wrapped in a scrolling container that only scrolls when it would otherwise overflow, embedded below or visit directly. Check the debug mode if you want it free of other Codepen code.

See the Pen MWeRJWd by Adrian Roselli (@aardrian) on CodePen.

Wrap-up

To set expectations, responsive in the web context generally means something adapts to viewport dimensions, typically width. This means a smart phone user and a zoomed desktop user will see the same view. This is not a proxy for touch, keyboard, nor other interaction types. These styles will likely need to go away for print as well.

Yes, you can apply CSS display properties to reflow the table, but some browsers break the semantics and a screen reader user will be stranded (yes, even at the end of 2020 this is still true). You will then have to add the semantics back with ARIA roles.

ARIA cannot replicate all the semantics. For example, if you use spanning cells, ARIA has no attribute that maps to the HTML headers attribute. If you have spanned content and are reflowing the table, things can get weird.

Whatever you do, avoid ARIA grid roles for responsive tables (and similarly, avoid using <div>s). ARIA grids are interactive widgets, not data tables (though they retain some traits of data tables). Just use HTML tables for your data.

Update: 4 December 2020

If you make a table that has no interactive bits in at all (no buttons for sorting, no links, no widgets, etc), and place it in the container I outlined above (giving it a tabindex="0"), and an Android 9/10/11 user comes to the table using Chrome (or any Chromium browser, but not Firefox) with TalkBack, then the table will be skipped. It doesn’t matter if you use a keyboard, explore-by-touch, or just have the page read to you.

I filed a bug with the Android folks and (after some back-and-forth) it was confirmed and reported… up the chain? Anyway, go confirm or test or star or whatever the kids do with the platform bugs nowadays: #174769542: TalkBack does not recognize tables in parents with tabindex

Update to Other Update: 23 March 2021

This is an update on the previous update. Last night the bug got its own update, saying the TalkBack / Chrome bug would be fixed in the Chrome 91 update. That update should land after 25 May 2021, so be sure to update then. And now you are updated.

Update: 23 June 2021

This post does not use CSS scroll snap. That is intentional. Two reasons why I did not implement it:

  1. A Chrome bug from 2018, Issue 835301: [css-scroll-snap][position-sticky] scroll-snapping “stickily” positioned elements can cause inaccurate snap positions, which was not fixed with the Chrome 91 table fixes;
  2. In testing, users often wanted to straddle a cell to compare with others, and on smaller screens, higher zooms, or fuller cells, this became particularly difficult.

I forked the pen I use in my Fixed Table Headers post (where this impact is more pronounced), added scroll-snap-align: start to the ths and tds, and added scroll-snap-type: both mandatory to the wrapper. You will see in Chrome the caption gets clipped on vertical scroll and the second cell can be clipped on horizontal scroll.

Once you factor in users who resize text or zoom, things can get problematic. Test your scroll snap solution against WCAG SCs 1.4.4 Resize Text, 1.4.10 Reflow, and 1.4.12 Text Spacing. Then try it with just the keyboard.

See the Pen Fixed Table Header Demo: Responsive with Scroll Snap by Adrian Roselli (@aardrian) on CodePen.

If you want to use scroll snap, test the heck out of it both with users and in browsers.

15 Comments

Reply

Friendly heads up, in the last paragraph of your CSS section, you have [tabindex=”0″] twice. I think you meant to say “perhaps by using [tabindex] instead of [tabindex=”0″]” to match the pattern you used for the role attribute. Great write up as always.

Jean Ducrot; . Permalink
In response to Jean Ducrot. Reply

Fixed, thanks!

Reply

Great, I would have locked also the left part of the table

Nicola Di Marco; . Permalink
In response to Nicola Di Marco. Reply

Nicola, in the section Usability Bonus above I reference another post of mine that shows you how to do just that:

I put them to use in my post Fixed Table Headers (which is mostly a longer version of this post).

It seemed redundant to include all that code in here again given the scope of this post.

Reply

Hi Adrian. You may also want to explicitly note that you add the parent with role="region" so as to not overwrite the native semantics of the table element.

Nikolas; . Permalink
In response to Nikolas. Reply

Nikolas, I assume you were referring to the parent <div>. I do not use the <div> to avoid overriding the table semantics. The <div> is there solely to act as the scrolling container for the table. At no point is the table at risk of having semantics overridden in this approach either by CSS display properties or by an errant ARIA role.

The only reason the <div> gets a role is because as the scrolling container it can receive focus and:

Anything that receives focus must have an accessible name and role that can be programmatically determined in order to satisfy WCAG SC 4.1.2 Name, Role, Value (A). […] I use region as the role since it is a generic landmark.

Reply

Lovely, comprehensive, and simple solution. Thanks for sharing!

Sean; . Permalink
Reply

This is great info! One issue; there isn’t a single accessible table in the entire article! The one inside the CodePen iFrame isn’t accessible via the keyboard.

In response to John. Reply

John, there is only one table in the post, the one in the Codepen iframe. So do you mean the iframe cannot be accessed with the keyboard? Or the table’s wrapper? Or do you mean the table (which itself does not take keyboard focus)?

If it has anything to do with the latter two (not the Codepen iframe), then try the example in debug mode and let me know what browser you are using. Then I can see if I can re-create it.

Reply

Regarding the 2020-12-04 update, the Google issue tracker is awful beyond belief. There are two actionable items on the issue, star and flag. Neither of them means what I thought they mean, and with a touch interface that is only discoverable by doing the wrong thing first. I won’t hold my breath on that one. :-(

Eric; . Permalink
Reply

Very nice Adrian.
To cover the condition where the table width is less than its container, may I suggest:

[role="region"][aria-labelledby][tabindex] {
  display: inline-block;
  max-width: 100%;
  overflow: hidden;
  overflow-x: auto;
  border: 0.1em solid #d6d6d6;
}

Example: Font-size comparison table

In response to Mike. Reply

Mike, I appreciate that you do not want the border to extend past the table. Using display: inline-block is a non-starter, however, since some browsers historically break the semantics of the table when encountering that style. This is still true (see slides 15 and 17). If you have a solution that does not use CSS display properties, then by all means give it a shot.

Reply

Having the region and the table with the same text could be a little confusing. Adding an aria-label and self-referencing using aria-labelledby would be a good way to give it more more complete description.
i.e.

<div role="region" aria-labelledby="Caption01-region Caption01" tabindex="0" id="Caption01-region" aria-label="Scroling region for table: ">
 <table>
  <caption id="Caption01">Books in a Scrolling Container</caption>

It should read out, “Scrollable Region for table: Books in a Scrolling Container”

Andrew Northmore-Thomas; . Permalink
In response to Andrew Northmore-Thomas. Reply

Glad to see the code black worked.
Here is my codepen for it.
https://codepen.io/Andy-NT/pen/bGgxRxK

Andrew Northmore-Thomas; . Permalink
In response to Andrew Northmore-Thomas. Reply

Andrew, > and < have to be escaped (as &gt; and &lt;), except for the allowed elements, otherwise my comment form eats them as a security precaution. I added what I think was the HTML you wanted by copying it from your CodePen example.

I intentionally do not note the scrolling region in the accessible name. A blind screen reader user is unaffected and a sighted screen reader user has the visual cues. I also try to not be too verbose.

I compared our examples in JAWS and NVDA (my Mac is too far away). I tried reading from the top, using the virtual cursor, and using the Tab key. Regardless of approach, once I hear any announcement that tells me there is a table, I can start navigating in it. Or I can immediately press T if I am familiar with the pattern and start using the table.

Using my example with Chrome 89 / JAWS 2020:

Reading from top:
table with 5 columns and 10 rows Books in a Scrolling Container… (the scrolling container is not announced)
With virtual cursor:
Books in a Scrolling Container (minus the table context; the scrolling container is not announced)
Pressing Tab:
Books in a Scrolling Container table

Using your example with Chrome 89 / JAWS 2020:

Reading from top:
table with 5 columns and 10 rows Books in a Scrolling Container… (the scrolling container is not announced)
With virtual cursor:
Books in a Scrolling Container (minus the table context; the scrolling container is not announced)
Pressing Tab:
Scroling region for table: Books in a Scrolling Container table

Using my example with Firefox 87 / NVDA 2020.4:

Reading from top:
region table with 10 rows and 5 columns caption Books in a Scrolling Container
With virtual cursor:
region table with 10 rows and 5 columns caption Books in a Scrolling Container
Pressing Tab:
region Books in a Scrolling Container table with 10 rows and 5 columns Books in a Scrolling Container caption

Using your example with Firefox 87 / NVDA 2020.4:

Reading from top:
Scroling region for table: Books in a Scrolling Container region table Books in a Scrolling Container caption
With virtual cursor:
Scroling region for table: Books in a Scrolling Container region table with 10 rows and 5 columns caption Books in a Scrolling Container
Pressing Tab:
Scroling region for table: Books in a Scrolling Container region Books in a Scrolling Container table with 10 rows and 5 columns Books in a Scrolling Container caption

NVDA seems to ignore announcing the region name if it is the same as the table. This may be good or bad, depending on user experience and how verbose you want to be.

None of this means either of our suggestions is right or wrong. Choosing the right text is a function of testing with your users to see which gives the most context without being verbose. Similar to how I provide a few announcement options in my post on sortable column headers.

Leave a Reply to Eric Cancel 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>