Search topics
Visible text that screen readers skip
A shop draws its Sold out badge inside the product photo's link, which is hidden from screen readers as a duplicate, so a screen reader never says which mug is sold out.
<a class="photo" href="/mugs/glazed" aria-hidden="true" tabindex="-1"> The problem
Screen reader users can't tell which product is sold out, and find out only when they open it.
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.
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.
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.
2 of 10 groups affected
- No vision (affected)
- Low vision (affected)
- Colour vision (not affected)
- No hearing (not affected)
- Hard of hearing (not affected)
- No speech (not affected)
- Motor (not affected)
- Reach, strength (not affected)
- Cognitive (not affected)
- Seizures (not affected)
- Without vision Screen reader users hear the sold-out mug read like the others, open it and only then find they can't buy it.
- Limited vision People who look at part of the screen and listen at the same time hear one thing and see another, and have no way to tell which to trust.
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.
Try it
Compare which mug the screen shows as sold out with what a screen reader reads for each card.
The reading below is worked out from the cards as they are built: what aria-hidden takes out is left out. Then try each fix.
Broken Can you fix this?
- Speckled mug
£18
- Glazed mug
£22
- Tall mug
£20
Reading the cards from the top, a screen reader says:
The fix
Give each status in words that the screen and the screen reader share, and hide what doesn't apply from everyone.
- 1Never put words people need inside an element with aria-hidden, unless the same words are read beside it.
- 2Hide what doesn't apply from everyone, with hidden or display: none, not with opacity or a position off screen.
- 3Read the page with a screen reader and compare it with the screen, card by card.
<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>The badge outside the photo, only on the cards that are sold out
<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>Why this fix: Hide things from everyone at once
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.
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.
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.
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.
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.
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.
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.
Fixing this with an AI coding assistant? Get this guide as Markdown, or get every guide in your coding agent.
Verify the fix
5 checks, no mouse.
Test with: Screen reader
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, and decorative images in decorative images read out as file names or repeated text. 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.
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.
References
Keep this fixed as your code changes.
Turn guides like this into rules your coding agents and CI follow, then have the result retested with a keyboard and screen readers.