# Visible text that screen readers skip

Source: https://easeweb.dev/learn/hidden-content
Topics: Page structure > What is on screen is what screen readers read (WCAG 1.3.1)
The fix: Either write the badge outside the hidden photo link, only on the cards that are sold out, so the screen and the screen reader get the same words, or keep the badge in the photo as drawing and put the words in the product link as visually hidden text. Either way, don't render a badge on every card and fade it out, since opacity hides it from sight and nothing else.
Test with: screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F92 https://www.w3.org/TR/wai-aria-1.2/#aria-hidden https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-hidden https://www.w3.org/WAI/WCAG22/Techniques/css/C7 https://dequeuniversity.com/rules/axe/4.10/aria-hidden-body

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 shop sells mugs in a grid of cards: a photo, the mug's name as a link, and its price. When a mug sells out, a Sold out badge is drawn on its photo, where shoppers look first. The photo is a link to the same page as the name, so, following common advice, it is hidden from screen readers and taken out of the tab order, leaving one link per card. The badge sits inside it.

So on screen, the Glazed mug is plainly sold out. A screen reader reading the list says "Speckled mug, link, £18. Glazed mug, link, £22. Tall mug, link, £20", with nothing to set the Glazed mug apart. The shopper opens it, and only on the product page finds it can't be bought.

## Why it fails

Success Criterion 1.3.1 Info and Relationships asks that information shown by the presentation can be worked out from the code, or is in the text. "This mug is sold out" is information, and here the only place it is written is the badge.

`aria-hidden="true"` takes an element and everything inside it out of what assistive technology is given, and nothing else. It is right on a photo link that repeats the name link. But the badge inherits it, so the information it shows is not in the text screen readers get.

## Who is affected

The failure reaches anyone who gets the page as speech or braille, and nobody who checks the page only by looking can see it: mouse and keyboard users who can see the screen never meet it.

## What automation and AI miss

No axe rule looks at visible text under `aria-hidden`: `aria-hidden-focus` checks only for things that take focus, and the badge doesn't. The audit sends visible words under `aria-hidden` to review (`ew:aria-hidden-text-manual-check`), leaving out symbols, icon glyphs and words that are read again in the same card, since only a person can tell whether what is hidden is needed. Text that is faded out or moved off screen but still read is not reported at all: visually hidden text is a sound way to give screen readers words the screen doesn't, so code can't tell a meant hidden word from a stray one. The audit also reports `aria-hidden` on the body (axe's `aria-hidden-body`) and sends a data table marked `role="presentation"` to review (ANDI). AI assistants asked to "hide the duplicate image link from screen readers" can add `aria-hidden` to the link without looking at what else is inside it.

## Before: the badge inside the hidden photo link

```html
<ul class="products">
  …
  <li class="card">
    <a class="photo" href="/mugs/glazed" aria-hidden="true" tabindex="-1">
      <img src="/img/glazed-mug.jpg" alt="">
      <span class="badge">Sold out</span>
    </a>
    <a href="/mugs/glazed">
      Glazed mug
    </a>
    <p class="price">£22</p>
  </li>
  <li class="card">
    <a class="photo" href="/mugs/tall" aria-hidden="true" tabindex="-1">
      <img src="/img/tall-mug.jpg" alt="">
    </a>
    <a href="/mugs/tall">Tall mug</a>
    <p class="price">£20</p>
  </li>
</ul>
```

```css
.card {
  position: relative;
}
.badge {
  position: absolute;
  top: 0.75rem;
  left: 0.75rem;
}
```

Hiding the repeated photo link is right, but the badge is hidden with it. The only words that say the Glazed mug is sold out are inside `aria-hidden`, so a screen reader reads its card exactly like the Tall mug's.

## After: the badge outside the photo, only on the cards that are sold out

```html
<ul class="products">
  …
  <li class="card">
    <a class="photo" href="/mugs/glazed" aria-hidden="true" tabindex="-1">
      <img src="/img/glazed-mug.jpg" alt="">
    </a>
    <span class="badge">Sold out</span>
    <a href="/mugs/glazed">
      Glazed mug
    </a>
    <p class="price">£22</p>
  </li>
  <li class="card">
    <a class="photo" href="/mugs/tall" aria-hidden="true" tabindex="-1">
      <img src="/img/tall-mug.jpg" alt="">
    </a>
    <a href="/mugs/tall">Tall mug</a>
    <p class="price">£20</p>
  </li>
</ul>
```

```css
.card {
  position: relative;
}
.badge {
  position: absolute;
  top: 0.75rem;
  left: 0.75rem;
}
```

The template writes the badge only when the mug is sold out, as a sibling of the photo link rather than inside it, and the CSS still draws it over the photo. What the screen shows and what a screen reader reads now come from the same element: "Sold out, Glazed mug, link, £22". Where stock changes while the page is open, keep one badge per card and switch it with the `hidden` attribute, which hides it from everyone at once, never with opacity.

## After: the status in the product link's words

```html
<ul class="products">
  …
  <li class="card">
    <a class="photo" href="/mugs/glazed" aria-hidden="true" tabindex="-1">
      <img src="/img/glazed-mug.jpg" alt="">
      <span class="badge">Sold out</span>
    </a>
    <a href="/mugs/glazed">
      Glazed mug
      <span class="visually-hidden">, sold out</span>
    </a>
    <p class="price">£22</p>
  </li>
  <li class="card">
    <a class="photo" href="/mugs/tall" aria-hidden="true" tabindex="-1">
      <img src="/img/tall-mug.jpg" alt="">
    </a>
    <a href="/mugs/tall">Tall mug</a>
    <p class="price">£20</p>
  </li>
</ul>
```

```css
.card {
  position: relative;
}
.badge {
  position: absolute;
  top: 0.75rem;
  left: 0.75rem;
}
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
```

The badge stays where it was, as part of the hidden photo, and only on the sold-out card. The same words go into the product link as visually hidden text, so the link itself reads "Glazed mug, sold out", which is also what a screen reader user hears moving from link to link. The visible name still starts the link's name, so voice control users can say "Click Glazed mug". The template has to write both from the same stock value, or the two drift apart.

## Which option to use

Both pass 1.3.1, and both write a badge only where it applies. The badge outside the photo is one element read and seen alike, with nothing to keep in step; choose it when the badge can move in the markup. The words in the link keep the markup as it was and put the status where people moving by link will hear it, at the cost of a second copy of the words that the template must keep in step with the badge. Whichever you choose, hide what doesn't apply with `hidden` or `display: none`, never with opacity.

## Implementation decisions

**A badge on every card, faded out.** Moving the badge out of the photo link is only half the fix. A theme that writes the badge into every card and fades it out with `opacity: 0` on mugs in stock fails the other way: opacity, like a transparent colour or a position off screen, hides the badge from sight and leaves it in the text, so a screen reader says "Sold out" before every mug while the screen shows it on one. Write the badge only where it applies, or hide it with the `hidden` attribute or `display: none`, which hide it from everyone.

**Visually hidden text is not the failure.** Text that only screen readers get is sound when it repeats or completes what the screen shows, such as "sold out" in the link, or "Page" before a page number. It fails when it says something the screen doesn't, as a faded badge does. Use one shared class for it, built as above, rather than moving text far off screen, which can make the page scroll sideways in some layouts.

**What else is under aria-hidden.** A repeated photo link is a good place for `aria-hidden`, as long as it holds only the photo. A price, a rating or "Only 2 left" drawn on the photo has the same problem as the badge. When something under `aria-hidden` can take focus, the failure is a different one: see [aria-hidden content that keyboard focus still reaches](/learn/aria-hidden-focus).

**aria-hidden on the body.** Scripts for modals and menus sometimes put `aria-hidden="true"` on the `body` and forget to take it off. Current Chromium and WebKit (the engines behind Chrome and Safari) and Firefox from version 139 now ignore it there, as the draft of ARIA 1.3 asks, but older browsers may still hide the whole page from assistive technology. Remove it, and hide the page behind a modal with `inert` or a native dialog, as in [modals that leave keyboard focus behind them](/learn/modal-focus-management).

**A table marked as presentation.** `role="presentation"` or `role="none"` on a data table, such as a size chart, strips its rows, columns and headers, and a screen reader reads the cells as a run of words. That is W3C's failure F92 of 1.3.1. Remove the role from any table whose cells need their headers; keep it only on a table used for layout. A list whose role was removed has its own guide: [lists built from divs, icons and typed numbers](/learn/list-markup).

**Images.** An image that only decorates takes `alt=""`, as the photos here do, rather than `aria-hidden`; see [decorative images read out as file names or repeated text](/learn/decorative-images).

## Verify the fix

1. In the browser's accessibility tree, look at each element with `aria-hidden="true"`: none holds words people need that are not read elsewhere in the same card.
2. With a screen reader, read the product list from the top: the sold-out mug is called sold out, and no other mug is.
3. Tab through the product links with a screen reader: each sold-out product says so in its link, or just before it.
4. Compare the screen with what was read, card by card, on a phone as well as a desktop: every status shown is read, and nothing read is missing from the screen.
5. Read the card template: the badge is written only where it applies, or switched with `hidden`, and never faded out with opacity.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the card component, the theme or the stock display changes.

## Limits

This covers words that are shown but not read, or read but not shown. Focusable content under `aria-hidden` is covered in [aria-hidden content that keyboard focus still reaches](/learn/aria-hidden-focus), and decorative images in [decorative images read out as file names or repeated text](/learn/decorative-images). Whether a hidden word is needed, or a hidden phrase is meant for screen readers, is a judgement a person has to make, page by page. Screen readers and braille displays differ in how they read cards and links: test with the ones your users have.

---

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