# Layout tables read as data tables, or out of order

Source: https://easeweb.dev/learn/layout-tables
Topics: Tables and data > Tables used for layout are not read out as data (WCAG 1.3.1, 1.3.2)
The fix: In an email, put each product whole in one cell, use td rather than th, and give every layout table, nested ones too, role="presentation". On a web page, use a list of products laid out with CSS instead of a table.
Test with: screen reader, styles off, zoom
References: https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html https://www.w3.org/WAI/WCAG22/Understanding/meaningful-sequence.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F46 https://www.w3.org/WAI/WCAG22/Techniques/failures/F49 https://www.w3.org/TR/wai-aria-1.2/#presentation

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A homeware shop sends a weekly newsletter. Under "New this week" it shows three products side by side, each a photo with its name and price under it: an enamel mug for $18, a linen tote for $32 and a speckled mug for $24. The email is laid out with tables, as emails still are, because the classic Outlook for Windows draws email with Word's engine, which ignores most CSS layout. The same HTML is the "view in your browser" page.

There are two common ways the grid goes wrong. In the first, the template comes from an email framework that writes each column as a `th`, as Foundation for Emails does. A screen reader user reaching the products hears a table announced, with its rows and columns, and then each product as a column header, as if they were the headings of figures to compare. In the second, the grid is built row by row: a row of three photos, a row of three names and a row of three prices, so each column lines up on screen. Read from the top, it says "Enamel mug, Linen tote, Speckled mug, $18, $32, $24", and the person has to count back to learn that the tote is $32.

## Why it fails

Table markup tells assistive technology that the content is data, arranged in rows and columns under headers. WCAG 1.3.1 Info and Relationships asks that structure in the code matches what is shown, and a header cell in a table that only places products side by side states a structure that isn't there: failure F46 names `th`, `caption` and `summary` in layout tables. Browsers guess whether a table holds data: Chrome marks a plain table with no borders and no `th` as a layout table in its accessibility tree, and screen readers read a layout table's cells as plain content. A `th` ends the guessing: in Chrome's accessibility tree, the first build is a `table` whose column headers are named "Enamel mug $18" and "Linen tote $32".

A screen reader reads a table one row at a time, left to right, in the order of the markup. WCAG 1.3.2 Meaningful Sequence asks that when the order matters, the order in the code is the one that makes sense. In the second build, the meaning runs down each column, a photo, then its name, then its price, but the reading runs across the rows: failure F49, a layout table that doesn't make sense when read in order. Marking that table as presentation hides the table but not the order.

## Who is affected

On a phone, a screen reader user swipes through the email one piece at a time, in the same order as the markup, so a price several swipes away from its name is as far from it as it is when listening on a computer.

## What automation and AI miss

axe has no rule for header cells in a layout table: in the first build, its `th-has-data-cells` rule only lists the header cells for review, and the second build passes it. No tool can tell from the code alone whether a table holds data, or whether its reading order makes sense. This audit reports a finding when a `th` holds a whole table, a sign that the table lays content out, as in the first build. For every other table that looks like layout, one that holds another table, is marked as presentation, or has a row of pictures over a row of text and no header cells, it sends a lead that quotes the cells in the order a screen reader reads them, and says when a row of pictures sits over a row of text. An email an AI code generator writes with Foundation for Emails' `<columns>` tags gets the same `th` cells when it is built, because the framework's Inky compiler writes them, not the generator.

## Before: each column a header cell

```html
<table class="row">
  <tr>
    <th class="columns">
      <table>
        <tr>
          <th>
            <img src="enamel-mug.jpg" alt="">
            <p>Enamel mug</p>
            <p>$18</p>
          </th>
        </tr>
      </table>
    </th>
    <th class="columns">
      <table>
        <tr>
          <th>
            <img src="linen-tote.jpg" alt="">
            <p>Linen tote</p>
            <p>$32</p>
          </th>
        </tr>
      </table>
    </th>
    …
  </tr>
</table>
```

```css
.row th {
  padding: 12px;
  font-weight: normal;
  text-align: left;
  vertical-align: top;
}
```

The order is right: each product is read whole, in one cell. The cells are where it fails:

- Each column is a `th`, so the browser treats the outer table as a data table, and a screen reader announces a table with its rows and columns before the products.
- Each product is read as a column header, which heads nothing.
- No table has `role="presentation"` to say it only places content.

## Before: a grid built row by row

```html
<table class="row">
  <tr>
    <td>
      <img src="enamel-mug.jpg" alt="">
    </td>
    <td>
      <img src="linen-tote.jpg" alt="">
    </td>
    …
  </tr>
  <tr>
    <td>Enamel mug</td>
    <td>Linen tote</td>
    …
  </tr>
  <tr>
    <td>$18</td>
    <td>$32</td>
    …
  </tr>
</table>
```

```css
.row td {
  padding: 6px 12px;
  text-align: left;
  vertical-align: top;
}
```

- Each product is split over three rows, so a screen reader reads all the photos, then all the names, then all the prices.
- "$32" is read two items after "Linen tote", with nothing to tie the two together.
- The table has no `role="presentation"`, so whether it is announced as a table is left to the guesses of the browser and the screen reader.

## After: one product to a cell, in tables marked as presentation

```html
<table class="row" role="presentation">
  <tr>
    <td class="columns">
      <table role="presentation">
        <tr>
          <td>
            <img src="enamel-mug.jpg" alt="">
            <p>Enamel mug</p>
            <p>$18</p>
          </td>
        </tr>
      </table>
    </td>
    <td class="columns">
      <table role="presentation">
        <tr>
          <td>
            <img src="linen-tote.jpg" alt="">
            <p>Linen tote</p>
            <p>$32</p>
          </td>
        </tr>
      </table>
    </td>
    …
  </tr>
</table>
```

```css
.row td {
  padding: 12px;
  text-align: left;
  vertical-align: top;
}
```

1. Make every cell a `td`. Nothing in the grid heads anything, so nothing is a `th`.
2. Put each product's photo, name and price in one cell, so it is read whole, and the next product starts only after it.
3. Give every table `role="presentation"`, the nested ones too. The role reaches the table's own rows and cells but not a table inside them. A screen reader now reads the three products as plain content, with no table announced.

This fixes both Befores and passes 1.3.1 and 1.3.2, as long as every table the template writes for layout has the role, including the wrappers, spacers and buttons that emails draw with tables.

## After: a list laid out with CSS, on a web page

```html
<ul class="products" role="list">
  <li>
    <img src="enamel-mug.jpg" alt="">
    <p>Enamel mug</p>
    <p>$18</p>
  </li>
  <li>
    <img src="linen-tote.jpg" alt="">
    <p>Linen tote</p>
    <p>$32</p>
  </li>
  …
</ul>
```

```css
.products {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
  gap: 24px;
  margin: 0;
  padding: 0;
  list-style: none;
}
```

1. Replace the tables with a list, one item to a product, its photo, name and price inside. A screen reader reads each product whole and announces a list of three items.
2. Add `role="list"` to the list. Safari drops the list role from a list styled with `list-style: none`, so without it VoiceOver reads the products without saying how many there are.
3. Lay the list out with CSS grid. The products sit side by side in a wide window and stack in a narrow one, in the order they are read.

This fixes both Befores and passes 1.3.1 and 1.3.2 on a web page, such as the shop's own "New this week" section. It doesn't work in an email: the classic Outlook for Windows ignores CSS grid, so the products fall one under another.

## Which option to use

Both pass. In an email, use the presentation tables: they are the layout that every mail client draws, including the classic Outlook for Windows. On a web page, use the list: it needs no table at all, stacks on a narrow screen without a media query, and tells a screen reader user how many products there are.

## Implementation decisions

**A framework that writes th.** Foundation for Emails writes each column as a `th` in a table with no role, so every email built with its `<columns>` tags carries the first Before. If you can't change what it writes, `role="presentation"` on every table it writes still makes those `th` cells plain content in Chrome's accessibility tree; F46 as written still counts any `th` in a layout table, so replace them where the template lets you. MJML puts `role="presentation"` on the tables it writes.

**presentation or none.** ARIA defines `role="none"` as a synonym for `role="presentation"`. Email frameworks such as MJML write `presentation`, and that is what this guide uses.

**Data in an email.** An order confirmation's list of items, quantities and prices is data. Keep it a data table, with header cells and a caption, as in [data tables with headers that are only bold text](/learn/table-headers), and never mark it as presentation.

**Borders change the browser's guess.** Chrome treats a table of plain cells with borders between them as data, even with no `th`, so a layout table drawn with lines needs `role="presentation"` as much as one with header cells.

**The photos.** Each photo here has an empty `alt`, because the name under it already says what it is; an `alt` that repeated the name would be read twice. Where a photo shows what the words don't, such as the colour of a mug, describe that; see [decorative images read out as file names or repeated text](/learn/decorative-images).

**Stacking on a phone.** Emails often stack their columns on a narrow screen by setting each cell to full width. The cells stack in markup order, so the grid built row by row stacks as three photos, three names and three prices: the order problem becomes visible there too.

## Verify the fix

1. In the email or page template, find every table that only places content, nested ones included, and confirm each has `role="presentation"`.
2. Confirm that no layout table has a `th`, a `caption` or a `summary` attribute, and that tables holding data have not been marked as presentation.
3. Read the markup in order with the tables taken away, and confirm each product's photo, name and price come together, one product at a time.
4. With a screen reader, read the email from the top: no table, row, column or header is announced around the products, and each product is read whole before the next.
5. Narrow the window, or open the email on a phone: the products stack in the order they are read.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the template changes.

## Limits

This is a starting pattern, not a guarantee for every email. Mail clients differ, and a webmail service shows the email inside its own page, so test in the clients your customers use. Whether a table without a role is announced depends on the guesses of each browser and screen reader. Reading order on a page laid out with CSS, where the order on screen is changed by the layout rather than by a table, is covered in [reading order that doesn't match the layout](/learn/reading-order).

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
