ARIA: the little you need and the lot you do not
ARIA is a set of attributes that tell assistive technology what an element is and what state it is in. It is genuinely necessary sometimes, and most of the ARIA in the world is doing harm.
So this lesson is mostly about not using it, which is the opposite of how the subject is usually taught.
The first rule
If you can use a native HTML element or attribute instead of ARIA, do that.
That is the actual first rule of the specification's own authoring practices, and it is the whole lesson in one line.
<div role="button" tabindex="0" aria-pressed="false">Save</div>
<button type="button" aria-pressed="false">Save</button>
The second is shorter, responds to Enter and Space, is disableable, is in the buttons list, and cannot be got wrong. The first needs two keydown handlers and still behaves differently.
ARIA changes what assistive technology is told. It changes nothing else. It does not
add keyboard support, does not add focusability, does not add click handling, and does not
change how anything looks. role="button" on a <div> produces something announced as a
button that a keyboard user cannot activate — which is worse than an unlabelled div,
because now it also lies.
The five rules
The specification states five, and they are short enough to learn:
1. Use native HTML if you can. As above.
2. Do not change native semantics unless you really must.
<h2 role="tab">Delivery</h2> <!-- the heading is now gone -->
<div role="tab"><h2>Delivery</h2></div> <!-- keep both -->
Putting a role on an element destroys its own role. That h2 is no longer in the
headings list.
3. All interactive ARIA controls must be usable by keyboard. If you build it, you implement every expected key — arrows, Home, End, Escape — as the authoring practices specify for that widget.
4. Do not use role="presentation" or aria-hidden="true" on a focusable element.
You would create something a keyboard user can reach and a screen reader will not
announce. Focused, and silent.
5. Every interactive element needs an accessible name. A button, a link, a form field. Ideally from its visible text.
The ARIA worth actually using
A short list. Almost everything you need is here.
aria-label and aria-labelledby
For naming something with no visible text:
<button type="button" aria-label="Close">
<svg aria-hidden="true" focusable="false">…</svg>
</button>
<nav aria-label="Breadcrumb">…</nav>
aria-labelledby is better when visible text exists, because it cannot drift out of
sync and it is translated with the page:
<section aria-labelledby="delivery-h">
<h2 id="delivery-h">Delivery</h2>
</section>
Two things go wrong with aria-label:
It replaces the element's text entirely. <button aria-label="Submit">Send enquiry</button>
is announced "Submit" — so a voice-control user saying "click send enquiry" gets nothing,
because the accessible name no longer matches what they can see. WCAG 2.5.3 requires the
visible text to be part of the accessible name. If there is visible text, do not
override it.
It does not work on every element. aria-label is ignored on generic elements like a
plain <div> or <span> with no role. People add it, see nothing announced, and
conclude ARIA is broken.
aria-describedby
Attaches supplementary text — a hint, or an error:
<label for="pin">Pincode</label>
<input id="pin" inputmode="numeric" aria-describedby="pin-hint pin-error"
aria-invalid="true">
<p id="pin-hint">Six digits, not starting with zero.</p>
<p id="pin-error">That pincode is outside our delivery area.</p>
Read after the label, and it takes a space-separated list of ids. This is the correct way to associate a hint or an error with a field, and it is the one piece of ARIA most forms genuinely need.
aria-expanded, aria-current, aria-pressed
State, on a control that already works:
<button type="button" aria-expanded="false" aria-controls="menu">Menu</button>
<div id="menu" hidden>…</div>
<a href="/prices/" aria-current="page">Prices</a>
<button type="button" aria-pressed="true">Bold</button>
aria-expanded is the single most useful ARIA attribute after labelling — it tells a
listener whether the thing is open. aria-current="page" marks the current item in a
navigation, which is otherwise conveyed by colour alone and therefore fails the previous
lesson.
Note that <details>/<summary> from module 1 handles the expanded state natively,
so it needs none of this.
aria-hidden="true"
Hide decoration from assistive technology:
<span aria-hidden="true">⚠</span> Enter a valid number
<svg aria-hidden="true" focusable="false">…</svg>
For an inline SVG icon, aria-hidden="true" and focusable="false" — the second
because older Internet Explorer made SVGs focusable, and it is still the habit in every
icon library.
Never on anything focusable or containing anything focusable. Rule 4.
aria-live
For content that changes without a page load — the one case where you genuinely cannot do it in plain HTML:
<p id="status" role="status" aria-live="polite"></p>
polite waits for a pause; assertive interrupts and should be rare. role="status"
implies aria-live="polite" so the pair is belt and braces.
The critical detail: the region must exist in the DOM, empty, before you put anything in it. If you create the element and its text in one go, many screen readers announce nothing, because they were not watching an element that did not exist. This is the single most common reason a live region "does not work".
role="alert"
An assertive live region for something urgent. Use sparingly — it interrupts whatever the user was listening to.
What not to do
Do not add roles that match the element.
<nav role="navigation"> <!-- redundant -->
<button role="button"> <!-- redundant -->
<ul role="list"> <!-- redundant, usually -->
Harmless but noise. One genuine exception: list-style: none on a <ul> causes Safari to
drop the list semantics, so role="list" restores them. That is a real bug workaround,
not a general rule.
Do not use ARIA to paper over bad markup.
<div role="heading" aria-level="2">Prices</div>
<h2>Prices</h2>
Both are announced as a level-2 heading. The second cannot be got wrong, is styleable, and appears in the outline without you thinking about it.
Do not build widgets you could avoid. A custom select, tab set, accordion or modal
is hundreds of lines of keyboard handling and ARIA state, and there is a native or
nearly-native answer to most of them — <select>, <details>, <dialog>,
<datalist>. Module 1 covered these deliberately.
If you genuinely must build one, follow the WAI-ARIA Authoring Practices pattern for it exactly. It specifies every role, every state and every key. Improvising is how you get a tab set that announces as a tab set and cannot be operated.
Broken ARIA is worse than none
This is the point of the whole lesson.
With no ARIA, a <div> is announced as nothing in particular and a user knows to be
careful. With wrong ARIA, it is announced confidently as a button, or as expanded
when it is collapsed, and the user acts on that. You have replaced an absence of
information with misinformation.
The common versions: aria-expanded never updated when the menu opens. aria-label
disagreeing with the visible text. role="tablist" with no keyboard handling.
aria-hidden on a focusable element. And aria-labelledby pointing at an id that does
not exist, which is subtler than it sounds: the reference contributes nothing, so the name
falls through to the element's own text. A button reading "Save" with
aria-labelledby="missing-id" is still announced "Save", so the bug is invisible until
the day you put an icon in a button and get silence.
Every automated tool flags invalid ARIA, so the mechanical errors are catchable
cheaply. Whether aria-expanded is accurate is not.
Checking what you actually produced
The accessible name and role of an element are computed from several sources, and the only reliable way to know the result is to look.
In devtools: select the element, open Accessibility, and read the computed Name and Role. If the name is empty, or is not what somebody would say out loud when pointing at it, that is your bug.
Do this for every button, link and form field on a page once. It takes ten minutes and finds things you would not guess.
Check your work
The first rule. Use native HTML instead of ARIA wherever you can.
What ARIA changes. Only what assistive technology is told — not behaviour, keyboard support or appearance.
What a role does to an element's own semantics. Destroys them.
Why aria-label on a button with text is a problem. It replaces the name, so
voice control no longer matches the visible text.
Where aria-label is ignored. On generic elements with no role.
Why aria-labelledby is preferred. It reuses visible text, so it cannot drift and it
is translated.
What a broken aria-labelledby does. Contributes nothing, so the name falls through to
the element's own text — which hides the bug until there is no text to fall back to.
What most forms genuinely need. aria-describedby, plus aria-invalid.
The most useful state attribute. aria-expanded.
What aria-current="page" fixes. A current nav item conveyed by colour alone.
The two attributes for a decorative inline SVG. aria-hidden="true" and
focusable="false".
The commonest reason a live region does nothing. It did not exist, empty, before the content was added.
Why role="list" is occasionally legitimate. list-style: none drops list semantics
in Safari.
Why broken ARIA is worse than none. It replaces missing information with confident misinformation.
How to know what you actually produced. The computed Name and Role in the devtools Accessibility pane.
Practice
- Build a div-button with
role="button"andtabindex="0". Try Enter and Space. Then replace it with a<button>. - Put
role="tab"on an<h2>and check the headings list. - Add
aria-label="Submit"to a button whose text says "Send enquiry". Read the computed Name in devtools and explain the voice-control problem. - Put
aria-labelon a plain<div>and check whether anything is announced. - Replace an
aria-labelwitharia-labelledbypointing at a visible heading. - Point
aria-labelledbyat an id that does not exist on a button with text, and read the computed Name. Then remove the text and read it again. - Add
aria-describedbyandaria-invalidto a form field and listen to it. - Add
aria-expandedto a menu button, then deliberately never update it. Listen to it open. - Add
aria-current="page"to a navigation item and listen to the list. - Put
aria-hidden="true"on a<button>, then tab to it. - Build a live region by creating the element and its text together. Then create the empty region first and add text afterwards. Compare.
- Add
role="navigation"to a<nav>and listen for the duplication. - Apply
list-style: noneto a<ul>and check the list semantics in Safari, then addrole="list". - Open the WAI-ARIA Authoring Practices page for a tab set and count the keyboard interactions required.
- Read the computed Name and Role for every interactive element on one of your pages. Write down any that are empty or wrong.
Official documentation
- W3C — Using ARIA: the five rules — The rules, from the specification, with the reasoning for each.
- WAI-ARIA Authoring Practices Guide — The patterns for every widget, with the required keys and states. The page to follow exactly if you must build one.
- MDN — ARIA — Every role, state and property, with browser notes.
- MDN — ARIA states and properties — The full attribute list, for looking one up.
- W3C — Understanding WCAG 2.5.3 Label in Name — Why an
aria-labelthat disagrees with visible text is a failure. - W3C — Accessible Name and Description Computation — How the name you see in devtools is actually derived.
Next: testing all of it, hands on.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship