Search topics
Comparison tables with ticks and crosses that read as blank
A pricing table shows each plan's features as tick and cross icons hidden with aria-hidden, so a cell that says yes reads the same as an empty one.
<svg aria-hidden="true"> The problem
Screen reader users can't tell which plans include a feature, because a cell with a tick reads the same as a cell with a cross or with nothing
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.
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.
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.
1 of 10 groups affected
- No vision (affected)
- Low vision (not 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 and braille users get the plan and the feature for each cell, and no answer. They can't tell which plan has single sign-on, the one thing the table is there to say.
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.
Try it
Press Tab to move through the cells, and compare what you see with what is read
Broken 1: each Tab stands in for a screen reader moving to the next cell. A tick and a cross both have nothing to read.
Broken 2: the crosses are gone and those cells are empty. A tick and an empty cell have nothing to read, the same as each other.
Apply a fix. Go along the Single sign-on row. The one called Named ticks only leaves cells with nothing to read.
Broken Can you fix this?
| Feature | Starter | Team | Business |
|---|---|---|---|
| Unlimited projects | |||
| Shared folders | |||
| Single sign-on |
Screen reader says:
(Tab to a cell)
The fix
Put a word in every cell, including the ones with no tick
Ticks and crosses hidden by the icon library
<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>What is wrong2 points
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.
Hidden words in each cell
<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>How it is fixed3 steps
- Leave the icon hidden, as the library renders it. It no longer has to speak.
- Add a clipped
spanto 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. - 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.
Why this fix: Use native HTML first
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.
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.
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.
Design ideas
A comparison table can stay light and still say yes and no
Six ways to draw which plan has which feature. Four say it in words a screen reader can read, whatever the look, and two leave it to the eye.
In the design file
- Write the words for every kind of cell in the design file, such as Included and Not included, even where only a mark is drawn.
- Give every cell that means no something to read, not an empty box.
- Write the contrast beside each mark that carries the value, at least 3:1, and beside each word, at least 4.5:1.
- Draw the table at phone width too, and show where the words go when the columns narrow.
Passes
Feature Starter Team Business Single sign-on Not included Not included Included Words in soft pills Included in a soft green pill, at 7.74:1, and Not included as plain grey text, at 7.56:1 on white. The table still scans by colour, and the words say it for anyone who can’t see the colour or the mark. It takes more width than a mark, so keep the labels short.Feature Starter Team Business Members 5 50 Unlimited Single sign-on Not included Not included Included The value itself in each cell 5, 50 and Unlimited say more than three ticks would, and they are already text, so nothing needs naming. Use it where a feature has a limit, and write Not included in full rather than leaving a gap.Feature Starter Team Business Single sign-on Not included Not included Included A filled badge against an outlined dash Keeps the light look of marks: a tick in a filled green disc, white on #1e7b34 at 5.33:1, against a dash in a thin outline. The two differ in shape and weight as well as colour, and each cell also holds words for a screen reader that the design doesn’t show.Team
Included
- Shared folders
Not included
- Single sign-on
Two lists under each plan No table: under each plan, the features sit under an Included heading and a Not included heading, so the words are said once and every line needs no mark. It suits a phone, and comparing plans side by side gets harder.
Fails
Feature Starter Team Business Single sign-on Green and red dotsWCAG 1.1.1 Tidy, and quick to scan for anyone who can see them. But a dot has no words, so the cell has nothing to read, and the two colours are only 1.24:1 against each other, so colour alone keeps them apart.Team
- Shared folders
- Single sign-on
Features that aren’t included, faded outWCAG 1.4.3 A common pricing card: what the plan lacks fades to pale grey, at 1.87:1 on white, too faint to read comfortably. The fade is also the only sign, so a screen reader reads the line just as it reads one that is included.
Verify the fix
4 checks, no mouse.
Test with: Screen reader, Zoom
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 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, and icons inside buttons are in icon-only buttons with no accessible name.
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
- WCAG Understanding: non-text-content (opens in a new tab)
- WCAG Technique ARIA6 (opens in a new tab)
- lucide.dev: accessibility (opens in a new tab)
- docs.fontawesome.com: accessibility (opens in a new tab)
- getbootstrap.com: pricing (opens in a new tab)
- w3.org: Slide0520 (opens in a new tab)
- developer.mozilla.org: img_role (opens in a new tab)
EaseWeb service
Fixed it? Keep it from coming back.
I turn guides like this one into rules your coding agents follow and checks your CI runs. Then I retest by hand with a keyboard and screen readers, periodically.