# Comparison tables with ticks and crosses that read as blank

Source: https://easeweb.dev/learn/comparison-table-ticks
Topics: Images and media > SVG icons say what they mean, or are skipped when they only decorate (WCAG 1.1.1)
The fix: Put the value in every cell in words. Either add visually hidden text, or write the words beside the mark, or give each mark role="img" and an aria-label. Naming only the ticks and leaving the other cells empty meets 1.1.1, but leaves "not included" as an absence.
Test with: screen reader, zoom
References: https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA6 https://lucide.dev/guide/react/advanced/accessibility https://docs.fontawesome.com/web/dig-deeper/accessibility https://getbootstrap.com/docs/5.3/examples/pricing/ https://www.w3.org/2007/Talks/0706-atmedia/slides/Slide0520.html https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/img_role

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 pricing page compares three plans in a table. The plan names run across the top, the features run down the left, and each cell holds one small icon: a green tick where the plan includes the feature and a grey cross where it doesn't. A sighted visitor runs an eye along "Single sign-on" and sees that only Business has it. A screen reader user goes along the same row. The table's headers are marked up correctly, so the plan and the feature are there for each cell: "Starter, Single sign-on", "Team, Single sign-on", "Business, Single sign-on". The value is missing every time. Whatever a screen reader says for a cell with nothing to read, it says the same for all three, so the one question the table exists to answer, which plan has this feature, has no answer. The other common build draws only the ticks and leaves "not included" as an empty cell. With the tick hidden, a cell that means yes and a cell that means no are the same.

## Why it fails

The icons come from an icon library, and several libraries hide an icon unless told otherwise: Lucide's components and Heroicons' React components render `aria-hidden="true"`, and so do Font Awesome's SVG icons from a Kit or SVG + JS. That suits an icon beside a word that says the same thing. Here the template says only `<Check />`, so the attribute isn't even visible in the source, and the cell has no word. In Chromium's accessibility tree such a cell has no name and no children, exactly like `<td></td>`.

A tick in this table is not decoration. It is the cell's value. 1.1.1 Non-text Content asks for a text alternative that serves the equivalent purpose, and allows content to be ignored by assistive technology only when it is pure decoration, used only for visual formatting, or not presented to users. The hidden tick and cross carry the information, and they have no text alternative. An empty cell is a different case: it holds no non-text content, so 1.1.1 asks nothing of it, and that is why naming only the ticks can pass.

The same loss comes without the attribute. Older versions of Lucide render an SVG with no `aria-hidden`, and Chromium exposes that as an image with no name: nothing to read there either.

## Who is affected

Nothing sounds broken, which makes this hard to notice: the person hears plan names and feature names in good order, and only the answers are missing. Someone choosing a plan for a feature they need has to look for it elsewhere on the site, or ask.

## What automation and AI miss

axe's `svg-img-alt` and `role-img-alt` look at elements with an image role. Run on a table of these cells in Chromium, axe 4.13 reported an SVG with `role="img"` and no name, and nothing for the same SVG with `aria-hidden="true"`, for one with no attributes, or for an empty cell. Hiding an icon is right when it is decorative, and a checker can't tell that this one isn't. This audit sends a table with two or more cells holding only an unnamed SVG to review, as the lead `ew:table-icon-cells-manual-check`: code can see that those cells have nothing to read, but a person has to say whether the marks mean something. Anyone reading the code, or an AI assistant asked to review it, sees `<Check />` with no attribute at all, because `aria-hidden` appears only in the rendered page. Going along a row with a screen reader, or looking at the rendered cell in the accessibility tree, is what shows it.

## Before: ticks and crosses hidden by the icon library

```html
<table class="plans">
  <thead>
    <tr>
      <th scope="col">Feature</th>
      <th scope="col">Starter</th>
      <th scope="col">Team</th>
      <th scope="col">Business</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Shared folders</th>
      <td>
        <svg class="no" aria-hidden="true" viewBox="0 0 24 24">…</svg>
      </td>
      <td>
        <svg class="yes" aria-hidden="true" viewBox="0 0 24 24">…</svg>
      </td>
      <td>
        <svg class="yes" aria-hidden="true" viewBox="0 0 24 24">…</svg>
      </td>
    </tr>
    …
  </tbody>
</table>
```

```css
.plans svg { width: 20px; height: 20px; }
```

The headers here are right: each cell has its plan and its feature. What each cell says is where it fails:

- The tick is hidden from assistive technology, and the cell has no other content, so "included" is lost.
- The cross is hidden too, so "not included" is lost as well, and the two cells read the same.

## Before: hidden ticks beside empty cells

```html
<table class="plans">
  <thead>
    <tr>
      <th scope="col">Feature</th>
      <th scope="col">Starter</th>
      <th scope="col">Team</th>
      <th scope="col">Business</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Shared folders</th>
      <td></td>
      <td>
        <svg class="yes" aria-hidden="true" viewBox="0 0 24 24">…</svg>
      </td>
      <td>
        <svg class="yes" aria-hidden="true" viewBox="0 0 24 24">…</svg>
      </td>
    </tr>
    …
  </tbody>
</table>
```

```css
.plans svg { width: 20px; height: 20px; }
```

This differs only in having no cross. On screen the row is still clear, but:

- The tick is hidden, so the cell that means yes has nothing to read.
- The cell that means no is really empty, and it is now the same as the cell that means yes.

## After: hidden words in each cell

```html
<table class="plans">
  <thead>
    <tr>
      <th scope="col">Feature</th>
      <th scope="col">Starter</th>
      <th scope="col">Team</th>
      <th scope="col">Business</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Shared folders</th>
      <td>
        <svg class="no" aria-hidden="true" viewBox="0 0 24 24">…</svg>
        <span class="visually-hidden">Not included</span>
      </td>
      <td>
        <svg class="yes" aria-hidden="true" viewBox="0 0 24 24">…</svg>
        <span class="visually-hidden">Included</span>
      </td>
      <td>
        <svg class="yes" aria-hidden="true" viewBox="0 0 24 24">…</svg>
        <span class="visually-hidden">Included</span>
      </td>
    </tr>
    …
  </tbody>
</table>
```

```css
.plans svg { width: 20px; height: 20px; }
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
```

1. Leave the icon hidden, as the library renders it. It no longer has to speak.
2. Add a clipped `span` to each cell with the value in words, "Included" or "Not included". The cell now has that as its text, in a screen reader's table reading and in a browser's translation of the page.
3. In a table with empty cells, put "Not included" in the empty cell the same way. It stays empty on screen, and it now says no.

This fixes both Befores and passes 1.1.1. The table looks exactly as it did. On screen the value is still only a mark, or a blank, so anyone who doesn't read a tick as yes still has to guess.

## After: visible words beside each mark

```html
<table class="plans">
  <thead>
    <tr>
      <th scope="col">Feature</th>
      <th scope="col">Starter</th>
      <th scope="col">Team</th>
      <th scope="col">Business</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Shared folders</th>
      <td>
        <svg class="no" aria-hidden="true" viewBox="0 0 24 24">…</svg>
        Not included
      </td>
      <td>
        <svg class="yes" aria-hidden="true" viewBox="0 0 24 24">…</svg>
        Included
      </td>
      <td>
        <svg class="yes" aria-hidden="true" viewBox="0 0 24 24">…</svg>
        Included
      </td>
    </tr>
    …
  </tbody>
</table>
```

```css
.plans svg { width: 20px; height: 20px; }
```

1. Write the value beside the mark in each cell, "Included" or "Not included". Everyone reads the same words, whichever way they read a tick.
2. Leave the icon hidden. It now repeats the word beside it, so hiding it is right, and the words are read once.
3. In a table with empty cells, write "Not included" in them. The mark is optional then.

This fixes both Befores and passes 1.1.1. The cost is room: three columns of words are wider than three columns of icons, and a long table loses some of the quick scan that marks give.

## After: name each mark

```html
<table class="plans">
  <thead>
    <tr>
      <th scope="col">Feature</th>
      <th scope="col">Starter</th>
      <th scope="col">Team</th>
      <th scope="col">Business</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Shared folders</th>
      <td>
        <svg class="no" role="img" aria-label="Not included" viewBox="0 0 24 24">…</svg>
      </td>
      <td>
        <svg class="yes" role="img" aria-label="Included" viewBox="0 0 24 24">…</svg>
      </td>
      <td>
        <svg class="yes" role="img" aria-label="Included" viewBox="0 0 24 24">…</svg>
      </td>
    </tr>
    …
  </tbody>
</table>
```

```css
.plans svg { width: 20px; height: 20px; }
```

1. Take `aria-hidden` off each mark, and give it `role="img"` so it is exposed as one image.
2. Give each an `aria-label` that says its value here: "Included", "Not included". Not "check" or "x".
3. In a table with empty cells, draw a mark in them first, a cross or a dash. An empty cell has nothing to name.

This fixes both Befores and passes 1.1.1, as long as every "not included" cell has a mark. In the rendered page, check that the library has not kept `aria-hidden` on the icon: see "Naming an icon from a library" below.

## After: name only the ticks, and leave the other cells empty

```html
<table class="plans">
  <thead>
    <tr>
      <th scope="col">Feature</th>
      <th scope="col">Starter</th>
      <th scope="col">Team</th>
      <th scope="col">Business</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Shared folders</th>
      <td></td>
      <td>
        <svg class="yes" role="img" aria-label="Included" viewBox="0 0 24 24">…</svg>
      </td>
      <td>
        <svg class="yes" role="img" aria-label="Included" viewBox="0 0 24 24">…</svg>
      </td>
    </tr>
    …
  </tbody>
</table>
```

```css
.plans svg { width: 20px; height: 20px; }
```

1. Take `aria-hidden` off each tick and name it with `role="img"` and `aria-label="Included"`, as Bootstrap's pricing example does.
2. Leave the "not included" cells empty, and take out any cross, since a hidden cross is a mark with no text alternative.

This is the smallest change to the second Before. It passes 1.1.1, since every tick has a text alternative and an empty cell holds no non-text content. It is not enough on its own: "not included" is left to be worked out from silence, and an empty cell can't be told from one that was never filled in. Going along a row, the person has to have heard "Included" somewhere to know what the gaps mean.

## Which option to use

The first three each put the value in every cell, so each one passes 1.1.1 and says no as plainly as yes. Hidden words and named marks both keep today's look. Hidden words are plain text, so they need no ARIA and reach anything that works on text; named marks are exposed as images, some page translators skip `aria-label`, and they can't help a cell with no mark. So to keep the look, use hidden words. Visible words cost room in the row and help everyone who can see the table too, so choose them when there is room, or when your audience may not read a tick as yes. Naming only the ticks is a fair first step on a table already built that way, but it leaves the no unsaid, so add the words when you can.

## Implementation decisions

**One cell component.** Render the cell from one component that takes the value and writes the mark and its words together, so a cell can't have one without the other and a tick can't get the wrong word. The words go through translation with the rest of the page.

**Naming an icon from a library.** Check the rendered page, not the template, because libraries differ. Lucide removes its `aria-hidden` once you pass `aria-label`, `role` or `title`. Heroicons' React components keep `aria-hidden="true"` unless you override that prop, even with a `title`, so a label alone leaves the icon hidden.

**Cells that say more than yes or no.** A limit such as "Up to 5" or "Add-on" is already text, so it needs nothing. A mark with a footnote needs the note's meaning in its words too: "Included, on yearly billing".

**The same marks in a list of features.** Pricing cards often list features with a tick for each one included and a cross or struck-through line for each one that isn't, and some tables turn into such cards on a phone. With the marks hidden, this is worse than a blank: the list reads as plain text, so every feature sounds included. The fix is the same: put "Not included" in the words of each one that isn't.

**A tick doesn't mean yes everywhere.** A W3C talk on designing for international users notes that a check mark can indicate "incorrect" in some countries, such as Japan, and that localisation for Japan often converts check marks to circles. Visible words settle it wherever you sell. Tick and cross characters, rather than SVGs, are covered in [emoji read out by their names, not their meaning](/learn/emoji).

**Contrast.** A mark that carries the value needs 3:1 against its background under 1.4.11, and a pale grey cross can fall short. See [low-contrast form fields, icons and focus rings](/learn/non-text-contrast).

## Verify the fix

1. In the browser's accessibility tree, open a cell in each state on the rendered page. It should have the value as its name or text: "Included" or "Not included". Where only the ticks are named, the "not included" cells are empty, and that is the limit of that fix.
2. With a screen reader, go along a row. Each plan's value should be read in words, and a plan that has the feature shouldn't sound like a plan that doesn't.
3. Ask someone who can see the table to compare each mark with its word, row by row. No tick should say "Not included", and no cross should say "Included".
4. Narrow the page to phone width. If the table becomes cards or a list, read it again: the same values should be there in words, and a feature that isn't included shouldn't sound included.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat whenever the icon library, its version or the shared cell component changes.

## Limits

This is a starting pattern, not a guarantee for every table. Screen readers differ in what they say for an empty cell and in how they read an image inside a cell, so test with the assistive technology your users have. The words in a cell only help when the cell also has its headers: a value read as "Included" needs its plan and its feature, and a comparison table drawn with `div` elements or with bold text for headers fails there, as [data tables with headers that are only bold text](/learn/table-headers) shows. For icons outside a table, naming them and hiding the decorative ones is in [SVG icons with no name, or read when they should be silent](/learn/svg-icons), and icons inside buttons are in [icon-only buttons with no accessible name](/learn/icon-buttons).

---

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