🎛️ ARIA Roles and Attributes
When native HTML runs out of vocabulary — for a custom tab strip, a live status message, a scripted dropdown — ARIA supplies the missing words. This lesson teaches you the three parts of ARIA, the patterns you'll actually use, and the single most important rule: reach for it only when HTML can't do the job.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- State the First Rule of ARIA and explain why "no ARIA beats bad ARIA"
- Distinguish ARIA's three components: roles, properties, and states
- Apply landmark and widget roles, and provide accessible names with
aria-label/aria-labelledby - Build accessible tabs, accordions, and dialogs using established patterns
- Announce dynamic updates with live regions
- Recognise and fix the four common ARIA mistakes
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Make a custom star-rating widget fully accessible.
In This Lesson
What Is ARIA?
ARIA (Accessible Rich Internet Applications) is a set of HTML attributes, defined by the W3C, that describe the role, state, and properties of an element to assistive technologies. It doesn't change how an element looks or behaves — it only changes what a screen reader announces. ARIA fills the gaps that arise when developers build complex, JavaScript-driven widgets that native HTML has no tag for.
💡 A useful analogy: ARIA is a translator standing between your custom interface and a screen reader. When you build something HTML has no word for — a scripted tab strip, a live-updating notification — the screen reader shrugs. ARIA whispers the correct labels: "this is a tab, it's selected, it controls that panel." But like any translator, it only describes; it never does the work. You still have to wire up the keyboard behaviour yourself.
⚠️ The First Rule of ARIA
"If you can use a native HTML element or attribute with the semantics and behaviour you need already built in, then do so, instead of re-purposing an element and adding an ARIA role or state to make it accessible."
The corollary developers repeat constantly: no ARIA is better than bad ARIA. A wrong role actively lies to screen-reader users, making the page worse than plain markup.
element do this?} B -->|Yes| C["Use the native element
(e.g. <button>)"] B -->|No| D["Add the appropriate ARIA
(e.g. aria-expanded)"]
The Three Components
Every piece of ARIA falls into one of three buckets. Keeping them straight is most of the battle.
| Component | Answers | Changes over time? | Example |
|---|---|---|---|
| Role | What is this element? | No | role="tablist" |
| Property | What are its fixed characteristics? | Rarely | aria-label="Close" |
| State | What is its current condition? | Yes, via interaction | aria-expanded="true" |
The practical distinction between a property and a state is volatility. aria-label (a property) is set once and left alone. aria-expanded (a state) flips every time the user toggles the control — so your JavaScript must keep it in sync.
Important ARIA Roles
Landmark roles
Landmark roles mark the major regions of a page. In modern HTML you should almost always use the semantic element instead — the browser maps it to the landmark for you.
<!-- Preferred: the semantic element maps to the landmark -->
<header>...</header>
<!-- Only if you truly can't use the element -->
<div role="banner">...</div>
| Landmark role | HTML5 equivalent | Purpose |
|---|---|---|
banner | <header> | Site-wide introductory content |
navigation | <nav> | Groups of navigational links |
main | <main> | Primary content of the document |
complementary | <aside> | Supporting, separable content |
contentinfo | <footer> | Copyright, metadata, footer info |
search | <search> (newer) | Site search functionality |
form | <form> (when named) | A collection of form controls |
Widget roles
Widget roles describe interactive patterns HTML has no native element for. Common ones include tablist / tab / tabpanel, menu / menuitem, combobox, slider, and progressbar.
⚠️ A role is a promise you must keep
Adding role="button" tells the screen reader "this behaves like a button." You've now promised it is focusable (tabindex="0") and responds to Enter and Space. A role without matching keyboard behaviour is a broken promise — which is exactly why a real <button> is almost always better.
Names, Properties & States
Giving elements an accessible name
Three attributes provide the name a screen reader announces. They map neatly onto building signage:
<!-- aria-label: the name is a string you supply -->
<button aria-label="Close dialog">×</button>
<!-- aria-labelledby: the name comes from another element -->
<h2 id="weather-heading">Weather Forecast</h2>
<section role="region" aria-labelledby="weather-heading">
...
</section>
<!-- aria-describedby: extra description, read after the name -->
<input type="password" aria-describedby="pw-help">
<p id="pw-help">At least 8 characters, including a number.</p>
aria-label is a label printed directly on a door; aria-labelledby is a sign pointing to the door; aria-describedby is the fine-print instructions posted beside it.
Frequently used states & properties
| Attribute | Meaning |
|---|---|
aria-expanded="true|false" | Whether a collapsible element is open |
aria-selected="true|false" | Selection state, e.g. the active tab |
aria-checked="true|false|mixed" | State of a custom checkbox or radio |
aria-hidden="true" | Hide a purely decorative element from AT |
aria-disabled="true" | Perceivable but not operable |
aria-current="page" | The current item in a set (e.g. active nav link) |
aria-live="polite|assertive" | How updates in a region are announced |
Common ARIA Patterns
Rather than invent your own, follow the patterns documented in the ARIA Authoring Practices Guide. Here are three you'll meet constantly.
Tabs
A tab strip needs three roles working together: tablist wraps the tabs, each tab controls a tabpanel, and aria-selected marks the active one.
<div class="tabs">
<div role="tablist" aria-label="Backend languages">
<button role="tab" aria-selected="true" aria-controls="p-js" id="t-js">JavaScript</button>
<button role="tab" aria-selected="false" aria-controls="p-py" id="t-py" tabindex="-1">Python</button>
<button role="tab" aria-selected="false" aria-controls="p-php" id="t-php" tabindex="-1">PHP</button>
</div>
<div role="tabpanel" id="p-js" aria-labelledby="t-js">JavaScript content...</div>
<div role="tabpanel" id="p-py" aria-labelledby="t-py" hidden>Python content...</div>
<div role="tabpanel" id="p-php" aria-labelledby="t-php" hidden>PHP content...</div>
</div>
Accordions
Each accordion header is a <button> whose aria-expanded reflects whether its region is open, and whose aria-controls points at that region.
<h3>
<button aria-expanded="false" aria-controls="sec1" id="sec1-btn">
Shipping & Returns
</button>
</h3>
<div id="sec1" role="region" aria-labelledby="sec1-btn" hidden>
Content for section 1...
</div>
Modal dialogs
If you can, use the native <dialog> element from the previous lesson. When you must build your own, this is the ARIA it needs:
<div role="dialog"
aria-labelledby="dlg-title"
aria-describedby="dlg-desc"
aria-modal="true"
id="confirm-dialog">
<h2 id="dlg-title">Confirm Action</h2>
<p id="dlg-desc">Are you sure you want to continue?</p>
<button>Cancel</button>
<button>Confirm</button>
<button aria-label="Close dialog" class="close">×</button>
</div>
The aria-modal="true" tells assistive technology that content outside the dialog is inert — but note it doesn't enforce that. You still have to trap focus with JavaScript, which is exactly why the native <dialog> is the better choice.
Live Regions
A live region announces content that changes without the user's focus moving there — a "saved" toast, a validation error, a count of new messages. Without a live region, a screen-reader user simply never hears about the update.
<!-- Polite: waits for a pause before announcing -->
<div aria-live="polite">Your settings have been saved.</div>
<!-- Assertive: interrupts immediately (use sparingly) -->
<div aria-live="assertive">Error: your session expires in 30 seconds.</div>
<!-- Role shortcuts: role="status" ≈ polite, role="alert" ≈ assertive -->
<div role="status">5 new messages.</div>
<div role="alert">Form could not be submitted.</div>
Think of polite as someone waiting for a gap in the conversation, and assertive as someone cutting in with urgent news. Reserve assertive for genuine emergencies.
📖 Extra live-region attributes
aria-atomic="true" — announce the entire region when any part changes, not just the changed node.
aria-relevant — which change types matter (additions, removals, text, all).
Gotcha: the live region must already exist in the DOM when the page loads. Injecting the region and its message at the same time often fails to announce.
Common Mistakes
Four failure modes account for the vast majority of broken ARIA. Learn to spot them.
1. Redundant ARIA
<!-- button already has an implicit button role -->
<button role="button">Click me</button>
<!-- h1 already announces as a level-1 heading -->
<h1 role="heading" aria-level="1">Page Title</h1>
2. Conflicting ARIA
<!-- Overriding a heading's meaning with a wrong role -->
<h1 role="button">This is not a proper button</h1>
<!-- A listitem with no parent list -->
<div role="listitem">Orphaned item</div>
3. Incomplete ARIA
<!-- tab with no aria-controls, panel with no role -->
<div role="tablist">
<div role="tab">Tab 1</div>
</div>
<div>Tab 1 content</div>
4. A role with no keyboard support
<!-- Broken: focusable? no. Enter/Space? no. -->
<div role="button" onclick="submitForm()">Submit</div>
<!-- Patched (but a real <button> is still better) -->
<div role="button" tabindex="0"
onclick="submitForm()"
onkeydown="if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); submitForm();
}">Submit</div>
Hands-on Exercise
🏋️ Make a Star-Rating Widget Accessible
Objective: This custom star rating works with a mouse but is invisible and unusable to keyboard and screen-reader users. Add the ARIA and keyboard support to fix it.
Starting markup:
<div class="star-rating">
<div class="rating-label">Rate this product:</div>
<div class="stars">
<span class="star" data-value="1">★</span>
<span class="star" data-value="2">★</span>
<span class="star" data-value="3">★</span>
<span class="star" data-value="4">★</span>
<span class="star" data-value="5">★</span>
</div>
</div>
Questions to guide you:
- What role expresses "choose one value from a range"?
- How do you make the widget focusable and keyboard-operable?
- How do you tell a screen reader the current rating?
💡 Hint
A star rating is conceptually a slider — one value between a minimum and a maximum. The container takes role="slider", tabindex="0", and the trio aria-valuemin / aria-valuemax / aria-valuenow. Arrow keys should raise and lower the value.
✅ Solution
<div class="star-rating">
<span id="rate-label">Rate this product</span>
<div class="stars"
role="slider"
tabindex="0"
aria-labelledby="rate-label"
aria-valuemin="0"
aria-valuemax="5"
aria-valuenow="0"
aria-valuetext="No rating">
<span class="star" aria-hidden="true">★</span>
<span class="star" aria-hidden="true">★</span>
<span class="star" aria-hidden="true">★</span>
<span class="star" aria-hidden="true">★</span>
<span class="star" aria-hidden="true">★</span>
</div>
</div>
const slider = document.querySelector('.stars');
const stars = slider.querySelectorAll('.star');
let rating = 0;
function setRating(value) {
rating = Math.max(0, Math.min(5, value));
slider.setAttribute('aria-valuenow', rating);
slider.setAttribute('aria-valuetext',
rating === 0 ? 'No rating' : `${rating} out of 5 stars`);
stars.forEach((star, i) =>
star.classList.toggle('selected', i < rating));
}
slider.addEventListener('keydown', (event) => {
if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
event.preventDefault();
setRating(rating + 1);
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
event.preventDefault();
setRating(rating - 1);
}
});
stars.forEach((star, i) =>
star.addEventListener('click', () => setRating(i + 1)));
Now the widget is one Tab stop, announces "slider, Rate this product, 3 out of 5 stars," and responds to arrow keys — while the decorative star glyphs are hidden from AT with aria-hidden.
🎯 Quick Quiz
Question 1: According to the First Rule of ARIA, when should you add an ARIA role to an element?
Question 2: Which attribute is a state that your JavaScript must update as the user interacts?
Question 3: You want a "Settings saved" message announced to screen readers without interrupting whatever they're currently reading. What do you use?
Summary & Quiz
🎉 Key Takeaways
- HTML first, ARIA second — reach for ARIA only when native elements can't express what you need; bad ARIA is worse than none.
- ARIA has three parts: roles (what it is), properties (fixed traits), and states (current, changing conditions).
- Provide accessible names with aria-label, aria-labelledby, and add detail with aria-describedby.
- Follow established patterns for tabs, accordions, and dialogs — and keep ARIA states in sync with JavaScript.
- Live regions announce dynamic updates; use
politeby default andassertiveonly for emergencies. - Avoid the four traps: redundant, conflicting, incomplete, and keyboard-less ARIA.
📚 Further Reading
- ARIA Authoring Practices Guide (patterns)
- WAI-ARIA 1.2 Specification
- MDN — ARIA
- Inclusive Components — Heydon Pickering
🚀 What's Next?
You've now completed the core of Module 3. Next you'll put semantic HTML, accessibility, and ARIA together in the Weekend Project — building a small, fully accessible multi-page site of your own.
🎉 Excellent!
You can now extend HTML's vocabulary responsibly — and you know the far more valuable skill of when to leave it alone.