ARIA, the scalpel of web accessibility
Table of contents
A precision tool
The WebAIM Million 2026 report is unequivocal: pages that use ARIA have more accessibility errors than those that don't. I explain why in ARIA, the poison of web accessibility, along with the four rules the W3C laid down to limit the damage.
But ARIA isn't a poison by nature. It's a scalpel: essential in the right hands, dangerous in the wrong ones. HTML has real limitations, and when you reach them, ARIA is the only answer.
This article will show you when ARIA is the right tool, which anti-patterns to avoid at all costs, and how to test in depth with a screen reader.
When ARIA is the right answer
If the W3C's first rule is "don't use ARIA", it's because ARIA exists for cases where HTML isn't enough. And those cases exist. Not on every page, but when you encounter them, ARIA is the only answer.
Content that changes without warning
A success message that appears after submitting a form. A cart counter that updates. A connection error notification. A chat message. In all these cases, content changes visually, but the screen reader doesn't know. It reads the page as it was on load. Without instructions to the contrary, the new content is invisible.
This is the problem solved by live regions — the only mechanism that lets you tell the screen reader "something changed, announce it." Sara Soueidan details every nuance in her reference series on the subject.
<!-- Non-urgent confirmation — the screen reader
waits for a pause in its reading -->
<p aria-live="polite">
Item added to cart (3 items).
</p>
<!-- Critical error — the screen reader
immediately interrupts its reading -->
<p aria-live="assertive">
Connection lost. Your changes are not saved.
</p>aria-live="polite" is a discreet notification: the screen reader waits until the user
is available before announcing the change. aria-live="assertive" is an interruption: it
cuts off what the screen reader is currently saying to announce the message immediately.
The rule is simple: if the user can wait, use polite. If the information is critical and
urgent, use assertive.
The <output> element carries an implicit status role with a polite live region, but
it's designed for calculation results, not generic notifications. For the general case —
from action confirmations to error alerts — no native HTML element covers the need. This is
a case where ARIA is the only answer.
Widgets that HTML doesn't have
HTML has buttons, links, checkboxes, text fields, dropdown lists. But it doesn't have tabs, expandable tree views, or rich comboboxes with filtering and groups.
The GOV.UK Design System and
Heydon Pickering in Inclusive Components
show how to build accessible tabs from a list of internal links: a <ul> with <li>
elements and <a href="#section"> links. Without JavaScript, it's a functional table of
contents. With JavaScript, the list becomes a tab group:
<!-- Without JavaScript: a table of contents -->
<ul>
<li><a href="#general">General</a></li>
<li><a href="#security">Security</a></li>
<li><a href="#notifications">Notifications</a></li>
</ul>
<!-- With JavaScript: ARIA roles
transform the semantics -->
<ul role="tablist">
<li role="presentation">
<a role="tab" href="#general" id="tab-general"
aria-controls="general" aria-selected="true">General</a>
</li>
<li role="presentation">
<a role="tab" href="#security" id="tab-security"
aria-controls="security" aria-selected="false"
tabindex="-1">Security</a>
</li>
<li role="presentation">
<a role="tab" href="#notifications" id="tab-notifications"
aria-controls="notifications" aria-selected="false"
tabindex="-1">Notifications</a>
</li>
</ul>
<section id="general" role="tabpanel" aria-labelledby="tab-general">
<!-- ... -->
</section>The <ul> becomes a tablist, the <a> elements become tabs — so far, the semantics
evolve with the behavior. But the <li> elements pose a problem: in a tablist, there's
no "list of items". If left as they are, the screen reader announces "list, 3 items, tab,
General" — a mix of two semantics that makes no sense. role="presentation" erases the
list item semantics without removing the element from the
DOM — this is exactly the case covered by
ARIA's second rule: modifying native semantics when they conflict with the element's actual
role.
The markup only takes you halfway. Each <a> points to its panel via aria-controls,
each panel receives role="tabpanel" and aria-labelledby, and JavaScript still has to
provide the keyboard layer: left/right arrows to move between tabs (the tabindex="-1"
takes inactive tabs out of the Tab order, that's the roving tabindex from the
APG pattern), toggling
aria-selected, hiding inactive panels. Without that layer, the component announces "tab"
but behaves like a list of links: the role's promise isn't kept.
The same principle applies to tree views (a keyboard-navigable hierarchy) or rich comboboxes
(a field with filtering, groups and keyboard navigation that <datalist> doesn't cover).
In all these cases, ARIA doesn't replace HTML — it complements it.
States that HTML can't express
A dropdown menu is open or closed. A link points to the current page. A field is associated with a help message. HTML can't express these states — ARIA does:
<!-- The button announces "Menu, collapsed" or
"Menu, expanded" -->
<button aria-expanded="false"
aria-controls="menu-navigation">
Menu
</button>
<ul id="menu-navigation" hidden>
<li><a href="/products">Products</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
<!-- The screen reader announces
"Home, link, current page" -->
<nav aria-label="Main navigation">
<a href="/" aria-current="page">Home</a>
<a href="/products">Products</a>
<a href="/contact">Contact</a>
</nav>
<!-- The help message is read after the field -->
<label for="email">Email address</label>
<input id="email" type="email"
aria-describedby="email-help">
<p id="email-help">
Used only for order confirmation.
</p>aria-expanded transforms a silent button into one that announces its state.
aria-current="page" tells the user where they are in the navigation — without it, all
links look the same. aria-describedby associates help text with a field without touching
its label: the screen reader reads the label first, then the supplementary description.
No native HTML attribute does this work. This is ARIA in its exact role: expressing what HTML cannot say.
The anti-patterns that poison
You now know when ARIA is the right answer. Léonie Watson navigates the web with a screen reader eight hours a day, and what she hears most often are the same errors coming back. Here are the most widespread.
The name that contradicts the label
<!-- ❌ The button displays "Search" but
the screen reader announces "Find a product" -->
<button aria-label="Find a product">
Search
</button>The user navigating by voice sees the word "Search" and says "click Search". Nothing
happens — because the button's accessible name is "Find a product", not "Search". The
aria-label replaced the visible text in the accessibility tree.
WCAG 2.5.3 Label in Name
(Level A) forbids it: the visible label must be contained in the accessible name. If
you want to add context, use aria-describedby — it supplements the name without replacing
it:
<!-- ✅ The visible text is the accessible name,
additional context is in the description -->
<button aria-describedby="search-help">
Search
</button>
<p id="search-help" hidden>
In the product catalog
</p>Content that's made to disappear
<!-- ❌ The out-of-stock product is grayed out visually,
the dev also hides it from the screen reader
— but the link remains focusable -->
<article class="product sold-out"
aria-hidden="true">
<h3>Wireless headphones</h3>
<p>$89.99 — <strong>Out of stock</strong></p>
<a href="/product/wireless-headphones">View product</a>
</article>The developer grayed out the out-of-stock product and thought it logical to also hide it
from the screen reader. But the information is still useful: the price, availability, the
link to the product page. And above all, aria-hidden="true" is inherited by all
descendants: the link disappears from the accessibility tree but remains focusable via
keyboard. The user lands on a mute element — no name, no destination.
Visually dimmed doesn't mean useless. If the content should remain on the page, it should remain in the accessibility tree:
<!-- ✅ The product remains accessible,
its state is announced clearly -->
<article class="product sold-out">
<h3>Wireless headphones</h3>
<p>$89.99 — <strong>Out of stock</strong></p>
<a href="/product/wireless-headphones">View product</a>
</article>
<!-- ✅ If the content truly needs to disappear,
hidden removes it from rendering AND the
accessibility tree -->
<article class="product sold-out" hidden>
<h3>Wireless headphones</h3>
<p>$89.99 — <strong>Out of stock</strong></p>
<a href="/product/wireless-headphones">View product</a>
</article>Roles that serve no purpose
<!-- ❌ Redundant — these HTML elements already
carry these roles implicitly -->
<nav role="navigation">...</nav>
<main role="main">...</main>
<header role="banner">...</header>
<footer role="contentinfo">...</footer>
<button role="button">Submit</button>
<a role="link" href="/contact">Contact</a>A <nav> is a navigation. A <main> is a main. A <button> is a button.
Adding the explicit role on an element that already carries it is like writing
color: red; color: red; in
CSS: it doesn't break anything, but it signals
the developer doesn't know what the element does natively. And this noise masks real
problems in an audit.
The aria-label nobody hears
<!-- ❌ aria-label on a paragraph —
ignored by most screen readers -->
<p aria-label="Important warning">
This product contains allergens.
</p>aria-label is not a universal attribute.
Ben Myers
documented it in detail: the
ARIA specification only allows it on elements whose
role accepts a name (widgets, landmarks, images) — not on static elements like <p>,
<span> or <div> without a role. And an explicit role isn't always enough: some roles
like presentation or generic also prohibit naming ("Name from: prohibited" in the
spec). In practice, most screen readers ignore it on these elements. The paragraph
text is read normally, the aria-label is lost.
If the visible text isn't enough, the solution isn't an invisible aria-label — it's
better visible text. I show a concrete example in
Anatomy of a product card, where each aria-label is
weighed case by case:
<!-- ✅ The text says what it needs to say,
for everyone -->
<p><strong>Warning:</strong>
this product contains allergens.</p>The button with no name
<!-- ❌ No accessible name — the screen reader
announces just "button" -->
<button>
<svg viewBox="0 0 24 24"><!-- icon --></svg>
</button>
<!-- ✅ aria-label when the icon alone
suffices visually -->
<button aria-label="Close the modal">
<svg viewBox="0 0 24 24" aria-hidden="true">
<!-- icon -->
</svg>
</button>
<!-- ✅✅ Even better: visible text
next to the icon -->
<button>
<svg viewBox="0 0 24 24" aria-hidden="true">
<!-- icon -->
</svg>
Close
</button>A button with just an SVG icon, with no text and no aria-label, is announced as
"button" — with no indication of what it does. aria-label fixes the problem for screen
readers, but not for people who navigate by voice: they see an icon and don't know which
word to speak to activate the button. Visible text is the most robust solution: it serves
as the accessible name, a reference point for voice navigation, and a fallback when
CSS doesn't load.
Testing in depth
The three 30-second checks from the previous article use the accessibility tree in the developer tools. That's a good first filter. But to test what the user actually hears, you need to activate a screen reader.
VoiceOver on macOS
Activate VoiceOver with Cmd + F5 (Fn + Cmd + F5 on recent keyboards, or a Touch ID triple-press). Navigate your page with Tab (interactive elements) and Ctrl + Option + arrows (sequential reading). Listen to what the screen reader announces for each element: the role, the name, the state.
The rotor (Ctrl + Option + U) is your structural audit tool. It displays the list of headings, links, landmarks and form controls. If an element is missing from the rotor when it should be there, or if it appears with an incomprehensible name, your ARIA has a problem.
Other screen readers
VoiceOver isn't the only tool, and your users aren't all on macOS:
- NVDA + Firefox on Windows: free and open source, the most widely used by developers on Windows. Navigate with Tab (interactive elements) and arrows (sequential reading). The elements list (Insert + F7) plays the same role as the rotor.
- JAWS + Chrome or Edge on Windows: the professional reference, paid. Same navigation logic, with the links list (Insert + F7) and the headings list (Insert + F6).
- TalkBack + Chrome on Android: the mobile screen reader. Navigate by swiping (left/right for sequential, up/down to change navigation mode). The reading menu (swipe down then right) gives access to headings, links and controls.
Each screen reader has its quirks, but the warning signs remain the same.
What to listen for
Four warning signs:
-
A role announced without behavior. The screen reader says "button" but Enter does nothing. You have a
role="button"without a keyboard handler. -
A name that doesn't match. The screen reader announces "Find a product" but you read "Search" on screen. You have an
aria-labelthat contradicts the visible text. -
Silence where you expect content. You navigate with Tab and focus stops on a mute element. You have an
aria-hidden="true"on a focusable element. -
An announcement that never comes. You submit a form, a success message appears, but the screen reader says nothing. You're missing an
aria-liveon the notification area.
Each of these signs corresponds to an anti-pattern from the previous section. The screen reader only reveals what the accessibility tree already contained.
The overall criteria
Every case you've just seen — live regions, tabs, states, anti-patterns — WCAG translates them into verifiable criteria:
- WCAG 1.3.1 Info and Relationships: information and relationships are programmatically determinable (Level A)
- WCAG 2.1.1 Keyboard: all functionality is operable through a keyboard interface (Level A)
- WCAG 2.4.4 Link Purpose (In Context): each link's purpose is determinable from the link text alone or its context (Level A)
- WCAG 2.5.3 Label in Name: the visible label is contained in the accessible name (Level A)
- WCAG 4.1.2 Name, Role, Value: name, role and value for every interface component (Level A)
- WCAG 4.1.3 Status Messages: status messages are programmatically determinable without receiving focus (Level AA)
All these criteria converge on the same idea: every role placed is a commitment — the component must behave as that role promises. Every accessible name is a contract — it must match what the user sees and can speak. The accessibility tree and the screen reader tell you whether you're keeping it.
Open your page, launch VoiceOver or NVDA, and listen. Thirty seconds is all it takes to hear what the screen reader announces.
You have the scalpel. Now use it.
Sources
- WebAIM Million 2026 — Accessibility analysis of the top 1,000,000 home pages
- W3C — Using ARIA
- W3C — ARIA Authoring Practices Guide, Read Me First
- W3C — WAI-ARIA 1.2 Specification
- GOV.UK Design System — Tabs component
- Heydon Pickering — Tabbed Interfaces, Inclusive Components
- Léonie Watson — Tink
- TetraLogical
- Ben Myers — Don't use aria-label on static text elements
- Sara Soueidan — Accessible notifications with ARIA Live Regions
- MDN — ARIA live regions
- W3C — F96: Failure due to the accessible name not containing the visible label text
- WCAG 2.2 — Understanding WCAG 2.2