Menu

Tables

Okay, this one is kinda tricky. There used to be a time when we used tables for page layouting. That’s wrong. At some point (around 2004) we realised that tables are not for designing, but for displaying data.

That's not the tricky part. What is tricky is the fact that a sighted person gets the whole idea of a table by looking at it - no matter what the underlying markup looks like - yet it’s imperative to use proper, semantically correct markup for assistive technology. And the "fun part" is that nearly every kind of assistive technology I’ve used handles tables differently. Sigh.

To give you an idea of what the different techs will read out when served bad and good markup, I'll write it down, and then we'll have a look at a video so you get a better feel for the problem with tables.

The simple table

We'll start with a fairly simple table. Our simple table is a <table> with some rows (<tr>) and some cells (<td>) in them. Each table we're looking at will have three columns and five rows - including the first one with the header items.

So our first markup looks something like this:

<table>
    <tr>
        <td>1st row, 1st cell</td>
        <td>1st row, 2nd cell</td>
        <td>1st row, 3rd cell</td>
    </tr>
    <tr>
        <td>2nd row, 2nd cell</td>
        <td>2nd row, 1st cell</td>
        <td>2nd row, 3rd cell</td>
    </tr>
</table>

Chromevox

Chrome's Chromevox extension is more for people with bad eyesight. Chromevox reads out the content of each cell that we click on. It won’t recognise or read out any relationship between cells and rows. This simple table won’t be recognised as a table in the first place. Just as a load of cell content...

VoiceOver

As soon as I move over to macOS' VoiceOver I'll also turn on its rotor (Ctrl+Alt+u). By switching to the "Tables" window within the rotor we'll see that the rotor identifies our simple table as columns 3, rows 5. That’s not really helpful.

screenshot of macOS rotor and how it list tables
Three tables - from very simple to more advanced. macOS's rotor will list each list by announcing how many rows and cells each table has. Unless we'll add a caption to the table.

Let's have a look at what VoiceOver reads out loud when navigating through the table with the arrow keys.

Navigating with right arrow key on the x-axis
Reads: "Product, column 1 of 2"
Navigating with right arrow key on the x-axis, changing from one row to the next (first cell in new row)
Reads: "Row 2 of 5, Peach, column 1 of 3"
Navigating with down arrow key on the y-axis
Reads: "Row 3 of 5, Cucumber"

Narrator

Window's Narrator won't recognise this table as a table. Using "Caps Lock"+Arrow-right Narrator first highlights the table, but doesn't announce it. Then it starts within the first cell.

Navigating with right arrow key on the x-axis
Reads: "Cucumber, non selected, column one, row three, cell"

Using the key combination "Caps Lock"+Arrow-right, Narrator will announce windows like we have them in VoiceOver's rotator. At some point we'll reach the "Tables". With three different types of tables on our test page, the simple table won’t be recognised. Narrator only finds two tables on our page. But for some reason I get to the first table after a fresh reload of the page. Don't ask me why.

NVDA

Interestingly enough, NVDA will recognise the simple table as a table. The screen reader announces a table with five rows and three columns, row one, column one.

Navigating with down arrow key on the x-axis
Reads: "column one, Product"
Navigating with down arrow key on the x-axis, changing from one row to the next (first cell in new row)
Reads: "Row two, column one, Peach"

A slightly better table with th

This table is basically the same as the previous one, but with one small difference. In the first row we're not using but for the cells. That stands for "table header" instead of "table data". This will make a difference.

So our second markup looks something like this:

<table>
    <tr>
        <th>1st row, 1st cell</th>
        <th>1st row, 2nd cell</th>
        <th>1st row, 3rd cell</th>
    </tr>
    <tr>
        <td>2nd row, 2nd cell</td>
        <td>2nd row, 1st cell</td>
        <td>2nd row, 3rd cell</td>
    </tr>
</table>

Chromevox

Again, Chromevox will just read the content of the cell we click on. But the first time we click on any cell, the browser's extension will say table. Just by adding header items with <th>, Chromevox will get the idea where the content comes from.

VoiceOver

VoiceOver's rotor will identify this table as columns 3, rows 5, too.

Navigating with right arrow key on the x-axis, first row
Reads: "Product, column 1 of 3"
Navigating with right arrow key on the x-axis, changing from one row to the next (first cell in new row)
Reads: "Row 2 of 5, Product, Peach, column 1 of 2"
Navigating further with right arrow key on the x-axis
Reads: "Price, 1.99 €, column 2 of 3"
Navigating with up/down arrow key on the y-axis
Reads: "Row 3 of 5, 0.99 €"

Because of the <th> VoiceOver - or any assistive technology for that matter - will get the idea that there’s a relationship between the header item and the content from the same column.

Narrator

As soon as we hit the table Narrator will say table, columns Product, Price, Vegan, contains 5 rows. Like Chromevox, Narrator now "sees" a table. What comes next is more sophisticated than what we saw in the previous table.

Navigating with right arrow key on the x-axis, first cell in first row
Reads: "table has 5 rows, 3 columns, Product, header-item, column header, row 1 of 5, column 1 of 3, non-selected"
Navigating further on with right arrow key on the x-axis
Reads: "Price, header-item, column header, column 2 of 3, non-selected"
Navigating with right arrow key on the x-axis, changing from one row to the next (first cell in new row)
Peach, non-selected, column header Product, row 2 of 5, column 1 of 3
Navigating further with right arrow key on the x-axis
Reads: "1.99 €, non-selected, column header Price, column 2 of 3"

I like how Narrator announces the value of a cell and then reads the corresponding column header. Finally it reads the "coordinates" such as the row and/or which column. That's the power of a little <th> ...

NVDA

NVDA is quite straightforward when it comes to reading this table. First, it will read table with 5 rows and 3 columns, row 1, column 1, Product.

Navigating with down arrow key on the x-axis, first cell in first row
Reads: "column 1, Product"
Navigating further on with down arrow key on the x-axis
Reads: "column 2, Price"
Navigating with down arrow key on the x-axis, changing from one row to the next (first cell in new row)
row 2, Product, column 1, Peach
Navigating further with down arrow key on the x-axis
Reads: "Price, column 2, one € and ninety nine cents"

A semantically correct table

In our last example we throw everything at the <table> we have in our arsenal, i.e. <caption>, <thead> and <tbody> as well as <th> and scopes.

Our last table looks like this:

<table>
    <caption>Things to eat</caption>
    <thead>
        <tr>
            <th scope="col">Product</th>
            <th scope="col">Price</th>
            <th scope="col">Vegan</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <th scope="row" class="th-inner">Peach</th>
            <td>1.99 €</td>
            <td>yes</td>
        </tr>
        <tr>
            <th scope="row" class="th-inner">Cucumber</th>
            <td>0.99 €</td>
            <td>yes</td>
        </tr>
        <tr>
            <th scope="row" class="th-inner">Cheeseburger</th>
            <td>7.99 €</td>
            <td>no</td>
        </tr>
        <tr>
            <th scope="row" class="th-inner">Eggplant</th>
            <td>1.35 €</td>
            <td>yes</td>
        </tr>
    </tbody>
</table>

First, let me explain a couple of things. The <caption> is some sort of headline for the <table>. You might think that's stupid. But let's have a look at VoiceOver's rotor. It will name this table Things to eat 3 columns, 5 rows. Always imagine you can't see and rely on technology that reads things for you. Now add a few more tables to our page and the <caption> really is a great aid! Don't ban it from your page. If your designer gets pesky, just apply our .visible-hidden (see skip link) to the caption. That way it’ll be hidden from your designer, but not from assistive technology.

Why not use a proper headline like <h3>instead? Well, it wouldn't get "attached" to the table. You wouldn't find the <h3>in the "Tables" window, but in the "Headlines" window of VoiceOver's rotor.

Place your row with all the header items in the <thead> and all the data in <tbody>. You could use <tfoot> as well, but in our case we'll concentrate on the important stuff. What about the scope attributes within the <th>s of the <thead>? The browser receives the information it needs to determine that the <th> is the "master" of this column.

Interestingly enough we can even add <th> to the (in this case) first cell of a row. That way, this <th>, in combination with the scope="row", becomes the "master" or header item of this row.

I added a little bit of CSS so the <th> within the <tbody> doesn't get the same styling as the main header item (blue background, bold font).

Chromevox

Let's be honest - Chromevox is nice for when you have difficulties reading a page. But it can't compete with the "big guns". So what does Chromevox do with our awesome <table>? The same as with the <table> before. It just reads table the first time we click on a cell and then it reads out the content of our cell. That's it.

VoiceOver

VoiceOver's rotor will identify this table as Things to eat, columns 3, rows 5. That is also what VoiceOver will read when we navigate to the table.

Navigating with right arrow key on the x-axis, first row
Reads: "Product, column 1 of 3"
Navigating with right arrow key on the x-axis, changing from one row to the next (first cell in new row)
Reads: "Row 2 of 5, Product, Peach, column 1 of 3"
Navigating further with right arrow key on the x-axis
Reads: "Price, 1.99 €, column 2 of 3"
Navigating with up/down arrow key on the y-axis
Reads: "Row 3 of 5, Cucumber, 0.99 €"

Narrator

The first time we hit this table with Narrator it’ll say Things to eat. Table. Columns Product, Price, Vegan. Rows Peach, Cucumber, Cheeseburger, Eggplant.

Navigating with right arrow key on the x-axis, first row
Reads: "row 1 of 5, Product, column 1 of 3, Product"
Navigating further on the x-axis
Reads: "header item, column header, column 2 of 3, Price"
Navigating further with right arrow key on the x-axis and changing the row
Reads: "header item, row column header Product, row 2 of 5, column 1 of 3, Peach"
Navigating with up/down key on the y-axis
Reads: "row 2 of 5, Peach, 1.99 €"

NVDA

With NVDA turned on, we'll hear this the first time we reach the table: table with 5 rows and 3 columns, Things to eat.

Navigating with down arrow key on the x-axis, first row
Reads: "row 1, column 1, Product"
Navigating further on the x-axis
Reads: "column 2, Price"
Navigating further with down arrow key on the x-axis and changing the row
Reads: "row 2, Product, column 1, Peach"
Navigating further with down key
Reads: "Price, column 2, one € and ninety nine cents"

Semantics!

We're coming to the same conclusion over and over again. Know your semantics! Using proper, semantically correct HTML will do the job. I’d just like to say that with good markup you'll have covered 90% of all possible accessibility issues.

Responsive and accessible

Don't do it!

Believe me, I played around with this topic for quite a while. Once I had my markup sorted and after testing it with different assistive technologies, I wanted to make it responsive. There are some really nice solutions available on the internet to create beautiful responsive tables. I like the idea where the <th> is hidden with a data attribute displayed in front of the first cell of a row instead. But all of the technologies I've tinkered around with had one flaw: They mess up the natural behaviour of a table.

One might use display: flex; or even display: grid; on a <table>. Don't do it! It’ll break the semantics of the table. Don't believe me? Okay. In the following example we have the same markup as before. The good markup from table three. But this time I added this little piece of CSS:

table tr {
    display: grid;
    grid-template-columns: repeat(3, [col] 1fr );
    grid-gap: 0;
}

Visually it looks exactly the same. But as soon as we fire up VoiceOver's rotor and switch to the "Tables" window, we'll see that something went totally wrong.

screenshot of macOS rotor and what he makes out of a messed up table
By applying a CSS that breaks the natural "flow of a table" rotor will recognise a table - but it contains just one cell and zero rows. That's wrong.

The display: grid; broke the table. Yes, rotor finds a table. But it only has one cell and zero rows. Thanks for that, dear smart-ass developer...

WAI-ARIA to the rescue?

You could argue that you can mess around with the semantics of a

by applying display altering CSS and then add some role="table", role="columnheader", role="row" and role="cell" to restore accessibility - that’ll do the trick! There’s just one big problem - it only does one trick, and that’s a first-class ticket to hell. Whenever possible, use the native HTML <table> element.

How stupid is this? First you create a well-designed and beautiful <table>. Then you destroy all the semantics for the sake of CSS. Finally you apply tons of unnecessary attributes just so you end up with a semantically correct <table> again. I’ll say it again: Don't do it!

The solution

Creating an accessible table is not that hard. Just use the correct semantic markup. Since we have the challenge of fitting our table onto small screens we can't just let them be as big as they want or need to be. Or can we?

Here’s one solution for accessible and responsive tables. It's the best solution so far.

Give your <table> a width of 100%. That way it’ll take all the available space. You've probably heard the question "Where should I add breakpoints to my design?" and the answer is "It depends. Wherever your content looks shabby, apply a breakpoint." That's not what we're gonna do here. I suggest this solution:

First we need an extra container around our <table>. I used a <div> and added a class selector called .table-scroller.

@media screen and (max-width: 480px) {
    .table-scroller {
        overflow-x: auto;
        width: 320px;
    }
}

You definitely want to play around a little bit with the widths. This little trick will keep the table fluid for some time - in this case up to 480px. Then the .table-scroller kicks in. The <table> stays 320px wide, but because of the overflow-x: auto; we end up with a scrollbar at the bottom of the <table>. On mobile devices we can scroll and get all the information we want, yet the <table> doesn't look broken. That way, we don't have to break the semantics of our <table> and don’t need to add extra attributes to our markup.

Video example

This video shows the three tables described on this page and how different assistive technologies will handle each table.