# Icons and images drawn only in CSS, with no text

Source: https://easeweb.dev/learn/css-images
Topics: Images and media > Icons and images drawn in CSS still say what they mean (WCAG 1.1.1, 1.3.1)
The fix: Put the information in the HTML, not only in CSS. Either keep the CSS picture and add a visually hidden word to each cell, or move the picture into the HTML as an img with alt text, or write the word in the cell and keep the picture only as decoration beside it.
Test with: screen reader, styles off
References: https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F3 https://www.w3.org/WAI/WCAG22/Techniques/css/C9 https://developer.mozilla.org/en-US/docs/Web/CSS/background-image#accessibility

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

An online shop builder compares its three plans, Starter, Shop and Pro, in a table. Each row is a feature: card payments, a custom domain, gift cards. Each cell shows a green tick if the plan includes the feature, or a grey dash if it doesn't. A sighted visitor sees at a glance that Shop adds a custom domain and only Pro has gift cards. A screen reader user moves down the Shop column and hears "Card payments, blank", "Custom domain, blank", "Gift cards, blank". Every cell in the table is read the same way. The headers are all there, and the one thing each cell is for, whether the plan includes the feature, is missing.

## Why it fails

Each tick and dash is drawn by CSS: the cell has a class, `yes` or `no`, and the stylesheet gives each class a background image. A CSS background image has no way to carry a text alternative, and browsers don't expose it to assistive technology at all, so the cell is empty to a screen reader. That fails 1.1.1 Non-text Content: the tick is non-text content that carries information, and it has no text alternative. W3C lists this as failure F3. 1.3.1 Info and Relationships asks that information shown through presentation can be determined from the code or is available in text, so that it is kept when the presentation changes, as when a user style sheet replaces the author's. The table's structure is right, with headers on every row and column, but what each cell says is held only by a class name and a stylesheet: nothing in the HTML says "included" or "not included", and with the styles off or replaced, the answer is gone.

## Who is affected

Browsers leave background images out when printing unless the person ticks "Background graphics" (Chrome) or "Print backgrounds" (Firefox), so a printed comparison loses its ticks too. Reader views, text-only browsers and pages with styles turned off show the empty cells in the same way.

## What automation and AI miss

axe and similar tools check the table's headers and pass them. They don't open the stylesheet to ask whether a background image means something, so a table of blank cells passes. ANDI warns about background images on any page that has one, as a reminder to check that they are decorative, but it can't tell which ones carry information. This audit also raises a lead for a person to check, one per table or list, wherever an empty element outside a link or button shows a CSS background or mask image or an icon font glyph: an icon-sized element, or a table cell or list item such as these. A quick AI fix may add `aria-label` to each `td`, which screen readers don't all read on a table cell, or `title`, which is not reliably read either. Only someone who knows the plans can say what each picture means.

## Before: ticks and dashes as background images in empty cells

```html
<table class="plans">
  <thead>
    <tr>
      <td></td>
      <th scope="col">Starter</th>
      <th scope="col">Shop</th>
      <th scope="col">Pro</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Card payments</th>
      <td class="yes"></td>
      <td class="yes"></td>
      <td class="yes"></td>
    </tr>
    <tr>
      <th scope="row">Custom domain</th>
      <td class="no"></td>
      <td class="yes"></td>
      <td class="yes"></td>
    </tr>
    <tr>
      <th scope="row">Gift cards</th>
      <td class="no"></td>
      <td class="no"></td>
      <td class="yes"></td>
    </tr>
  </tbody>
</table>
```

```css
.plans td { text-align: center; }
.plans .yes { background: url(/icons/tick.svg) center / 20px no-repeat; }
.plans .no { background: url(/icons/dash.svg) center / 20px no-repeat; }
```

Every data cell is empty in the HTML. The ticks and dashes exist only in the stylesheet, where nothing can give them words.

## After: a hidden word in each cell

```html
<table class="plans">
  <thead>
    <tr>
      <td></td>
      <th scope="col">Starter</th>
      <th scope="col">Shop</th>
      <th scope="col">Pro</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Card payments</th>
      <td class="yes"><span class="visually-hidden">Included</span></td>
      <td class="yes"><span class="visually-hidden">Included</span></td>
      <td class="yes"><span class="visually-hidden">Included</span></td>
    </tr>
    <tr>
      <th scope="row">Custom domain</th>
      <td class="no"><span class="visually-hidden">Not included</span></td>
      <td class="yes"><span class="visually-hidden">Included</span></td>
      <td class="yes"><span class="visually-hidden">Included</span></td>
    </tr>
    <tr>
      <th scope="row">Gift cards</th>
      <td class="no"><span class="visually-hidden">Not included</span></td>
      <td class="no"><span class="visually-hidden">Not included</span></td>
      <td class="yes"><span class="visually-hidden">Included</span></td>
    </tr>
  </tbody>
</table>
```

```css
.plans td { text-align: center; }
.plans .yes { background: url(/icons/tick.svg) center / 20px no-repeat; }
.plans .no { background: url(/icons/dash.svg) center / 20px no-repeat; }
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
```

The pictures stay where they were, and each cell now holds a word that says the same thing, clipped out of sight but still in the accessibility tree. A screen reader reads "Gift cards, Not included". The table looks exactly as it did; the words are in the HTML, so they are also there with styles off and are translated with the page. Like the ticks, they don't print.

## After: the tick as an image with alt text

```html
<table class="plans">
  <thead>
    <tr>
      <td></td>
      <th scope="col">Starter</th>
      <th scope="col">Shop</th>
      <th scope="col">Pro</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Card payments</th>
      <td><img src="/icons/tick.svg" alt="Included" width="20" height="20"></td>
      <td><img src="/icons/tick.svg" alt="Included" width="20" height="20"></td>
      <td><img src="/icons/tick.svg" alt="Included" width="20" height="20"></td>
    </tr>
    <tr>
      <th scope="row">Custom domain</th>
      <td><img src="/icons/dash.svg" alt="Not included" width="20" height="20"></td>
      <td><img src="/icons/tick.svg" alt="Included" width="20" height="20"></td>
      <td><img src="/icons/tick.svg" alt="Included" width="20" height="20"></td>
    </tr>
    <tr>
      <th scope="row">Gift cards</th>
      <td><img src="/icons/dash.svg" alt="Not included" width="20" height="20"></td>
      <td><img src="/icons/dash.svg" alt="Not included" width="20" height="20"></td>
      <td><img src="/icons/tick.svg" alt="Included" width="20" height="20"></td>
    </tr>
  </tbody>
</table>
```

```css
.plans td { text-align: center; }
.plans td img { vertical-align: middle; }
```

The ticks and dashes move out of the stylesheet into the HTML, where an image can have alt text. A screen reader reads "Gift cards, Not included", with "image" or "graphic" after it, depending on the screen reader. The table looks the same, and because the pictures are now content, not backgrounds, they also print.

## After: the word in the cell, the picture beside it

```html
<table class="plans">
  <thead>
    <tr>
      <td></td>
      <th scope="col">Starter</th>
      <th scope="col">Shop</th>
      <th scope="col">Pro</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Card payments</th>
      <td class="yes">Included</td>
      <td class="yes">Included</td>
      <td class="yes">Included</td>
    </tr>
    <tr>
      <th scope="row">Custom domain</th>
      <td class="no">Not included</td>
      <td class="yes">Included</td>
      <td class="yes">Included</td>
    </tr>
    <tr>
      <th scope="row">Gift cards</th>
      <td class="no">Not included</td>
      <td class="no">Not included</td>
      <td class="yes">Included</td>
    </tr>
  </tbody>
</table>
```

```css
.plans td { text-align: center; }
.plans .yes { padding-left: 28px; background: url(/icons/tick.svg) 4px center / 20px no-repeat; }
.plans .no { padding-left: 28px; background: url(/icons/dash.svg) 4px center / 20px no-repeat; color: #5f6368; }
```

Each cell says in words whether the plan includes the feature, and the tick or dash moves to the left of the word. The background image now only decorates: if it fails to load, is left out of a print or is dropped with the styles, nothing is lost. A screen reader reads "Gift cards, Not included". This changes the look: the table is wider and carries words, which also helps anyone who isn't sure what a dash means in a comparison.

## Which option to use

All three put the information in the HTML, so all three meet 1.1.1 and 1.3.1. The hidden word keeps the design exactly and adds only the words and a visually hidden class, which most sites already have, so it suits a table drawn by a theme whose CSS you can't otherwise change; like the ticks themselves, it doesn't print. The image with alt text also keeps the look, and prints, but swaps the stylesheet's icons for images in every template that draws the table. The word in the cell changes the design, and is the only option in which sighted visitors read the answer instead of decoding a symbol; it needs a wider table, so check it at phone width. Choose by what you can change: the template, the stylesheet, or the design.

## Implementation decisions

**Icon fonts.** An icon font draws its icons as characters inserted with `::before`, usually from Unicode's private use area. Screen readers may read generated content, but for such a glyph they say nothing or an odd character, and its meaning is lost in the same way. Put the glyph on an element with `aria-hidden="true"`, and give the cell or label its words as hidden or visible text, as in the fixes above.

**Text in `content`.** A label written only in CSS, such as `content: "New"` on a product card, can be read by a screen reader, as support has improved, but can't be selected, copied or found in page, and is easy to lose in a stylesheet change. Put words that carry information in the HTML.

**Logos and list bullets.** A logo set as the background of an empty link leaves the link with no name; that is the failure in [banner and image links with no name](/learn/empty-links). A list whose bullets are images that mean something (a tick for done, a cross for not started) loses that meaning the same way as the table: write it in each item.

**`aria-label` on cells.** Naming a `td` with `aria-label` looks like a shortcut, but screen readers differ in whether they read a cell's `aria-label`, and the words are still missing with styles off. Text inside the cell works everywhere.

**Decorative backgrounds are fine.** A photo behind a hero, a texture or a divider carries nothing, and CSS is the right place for it. The test is whether anything is lost when the image is gone.

## Verify the fix

1. With a screen reader, move down each plan's column with the table navigation keys. Every cell should be read as included or not included, and none as blank.
2. In the browser's accessibility tree, check that every data cell has a name or text: "Included", "Not included", or an image with that name.
3. Turn off CSS, or open the page in a reader view. Each cell should still say whether the plan includes the feature.
4. Ask someone who knows the plans to compare each word with the tick or dash it stands for, and confirm they match.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat whenever the pricing table's template or its stylesheet changes.

## Limits

This is a starting pattern, not a guarantee for every application. Paths, class names and the plans are placeholders. Screen readers read table cells in slightly different ways, so test with the assistive technology your users have. The same rule covers SVG and img icons used the same way; see [SVG icons with no name, or read when they should be silent](/learn/svg-icons) and [emoji read out by their names, not their meaning](/learn/emoji). Test the complete journey with your target assistive technology.

---

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