The College Website That Ravi Could Not Use
Ravi is a first-year student who is blind. He uses a screen reader, a program that reads the screen aloud and lets him move around with the keyboard. When admissions opened, he visited his college website to check the hostel rules and the list of courses.
The page had an FAQ section where you click a question to see the answer, and a row of course tabs: Science, Commerce, Arts. Sighted students clicked them happily. For Ravi, the screen reader said only “When does admission open, text”. Pressing Enter did nothing. The tabs were plain <div> elements with click handlers, so the keyboard could not even reach them.
The developer had built custom components: interactive widgets made from plain boxes and JavaScript. In this lesson you will learn how to make such components work for everyone, using ARIA (Accessible Rich Internet Applications). ARIA is a set of HTML attributes that tell assistive technology what an element is, what state it is in and how it relates to other elements. You already know the basics from the Accessibility Basics lesson. This lesson is the next step.
The First Rule of ARIA: Use Native HTML First
Here is the most important idea in this lesson: if a native HTML element already does the job, use it instead of adding ARIA. A real <button> gets keyboard focus, responds to Enter and Space, and is announced as “button”, all for free. A <div> gets none of this. ARIA only changes what the screen reader says. It never adds keyboard behaviour.
Run this example. Click both with the mouse. Then click in the result area and press Tab: the focus jumps straight past the fake button to the real one.
<style>
.fake-btn { background: #1e6fd9; color: white; padding: 8px 14px; display: inline-block; cursor: pointer; }
</style>
<!-- A div pretending to be a button: mouse users can click it, keyboard users cannot reach it -->
<div class="fake-btn" onclick="this.textContent = 'Applied!'">Apply for Admission</div>
<!-- A real button: works with mouse, Tab, Enter and Space, and screen readers call it a button -->
<button type="button" onclick="this.textContent = 'Applied!'">Apply for Admission</button>
For an FAQ, HTML already has a ready-made disclosure widget: <details> and <summary>. It opens and closes with mouse and keyboard, and screen readers announce whether it is expanded or collapsed. No JavaScript and no ARIA needed.
<h2>Frequently Asked Questions</h2>
<details>
<summary>When does the B.Sc. admission open?</summary>
<p>Online applications open on 1 June and close on 30 June.</p>
</details>
<details>
<summary>Is there a hostel for first-year students?</summary>
<p>Yes. Separate hostels for boys and girls are available inside the campus.</p>
</details>
Why this rule matters: bad ARIA is worse than no ARIA. A wrong role makes a screen reader promise something the element cannot do, and the user gets stuck.
Roles: Telling the Screen Reader What Something Is
Every element has a role, which is its job. A <button> has the role “button”, a <nav> has the role “navigation”, and a <div> has no meaningful role at all. The role attribute lets you set a role when no native element fits. Common examples:
| Role | Use it for | Native element to prefer |
|---|---|---|
button | Something that does an action | <button> |
tablist, tab, tabpanel | A tab widget | None exists, so ARIA is right here |
status | A polite message that updates | <output> in some cases |
alert | An urgent message, read at once | None |
img | A group of things that form one picture | <img> with alt |
Tabs are a good example of when ARIA is the correct tool. HTML has no tab element, so you build tabs from buttons and add role="tab". You will build them in the real-life example below.
Names and Descriptions: aria-label, aria-labelledby, aria-describedby
Every interactive element needs an accessible name: the words a screen reader speaks for it. Usually the name comes from the visible text, like the text inside a button or the <label> of an input. When there is no visible text, ARIA helps:
aria-label="..."gives a name directly as a string. Use it for icon-only buttons.aria-labelledby="id"takes the name from another element on the page. Prefer this when the text already exists, because it stays in sync and gets translated with the page.aria-describedby="id"adds extra help, such as a hint or an error message. The screen reader reads it after the name.
<!-- aria-label: the button shows only an icon, so we give it a name -->
<button type="button" aria-label="Search courses">🔍</button>
<!-- aria-labelledby: the name comes from text that is already on the page -->
<h2 id="fees-title">Fee Details</h2>
<table aria-labelledby="fees-title">
<tr><th>Course</th><th>Fee per year</th></tr>
<tr><td>B.Com</td><td>₹18,000</td></tr>
</table>
<!-- aria-describedby: extra help text that is read after the name -->
<label for="roll">Roll number</label>
<input id="roll" type="text" aria-describedby="roll-help">
<p id="roll-help">Write it exactly as printed on your hall ticket, for example 23BSC041.</p>
Building a Disclosure Button with aria-expanded and aria-controls
Sometimes you need more control than <details> gives, for example custom animation or a design team that wants a specific look. Then you build a disclosure: a button that shows and hides a section. Two attributes do the work:
aria-expandedtells the user whether the section is open ("true") or closed ("false"). Your script must update it on every click.aria-controlsholds theidof the section the button opens. It connects the two.
<style>
.faq-btn { width: 100%; text-align: left; padding: 10px; font-size: 1rem; }
.faq-answer { padding: 0 10px; }
</style>
<h3>
<button type="button" class="faq-btn" aria-expanded="false" aria-controls="ans-1">
What documents do I need for admission?
</button>
</h3>
<div id="ans-1" class="faq-answer" hidden>
<p>Bring your Class 12 marksheet, transfer certificate and two passport size photos.</p>
</div>
<script>
const btn = document.querySelector('.faq-btn');
const panel = document.getElementById('ans-1');
btn.addEventListener('click', () => {
const isOpen = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!isOpen));
panel.hidden = isOpen;
});
</script>
Notice that the button sits inside an <h3>. This keeps the question in the heading outline, so screen reader users can jump from question to question with the heading shortcut. The panel uses the hidden attribute, which hides it from everyone.
Live Regions: Announcing “Seat Booked”
When JavaScript changes text on the page, a sighted user sees it. A screen reader user hears nothing unless the change happens inside a live region. A live region is an element the screen reader watches. When its text changes, the new text is read aloud.
role="status"(same asaria-live="polite") waits until the user stops what they are doing, then reads the message. Use it for success messages.role="alert"(same asaria-live="assertive") interrupts at once. Use it only for urgent errors.
<h2>Book a Seat for the Orientation Day</h2>
<button type="button" id="book">Book my seat</button>
<!-- The live region exists from the start, empty. We only change its text. -->
<p id="status" role="status"></p>
<script>
const statusBox = document.getElementById('status');
let seatsLeft = 3;
document.getElementById('book').addEventListener('click', () => {
if (seatsLeft > 0) {
seatsLeft = seatsLeft - 1;
statusBox.textContent = 'Seat booked. ' + seatsLeft + ' seats left.';
} else {
statusBox.textContent = 'Sorry, all seats are full.';
}
});
</script>
The live region must be in the page before the message arrives. If you create the element and its text at the same moment, many screen readers miss it. So keep an empty <p role="status"> ready and only change its text.
hidden vs aria-hidden
These two look similar but do different things:
| Attribute | Seen on screen? | Read by screen reader? | Use it for |
|---|---|---|---|
hidden | No | No | Closed panels, inactive tabs |
aria-hidden="true" | Yes | No | Decorative icons, duplicate text |
<!-- hidden: removed for everyone, eyes and screen readers -->
<p hidden>This notice is not shown to anyone right now.</p>
<!-- aria-hidden: still visible, but screen readers skip it -->
<p>
<span aria-hidden="true">★★★★☆</span>
<span>Rated 4 out of 5 by students</span>
</p>
The stars are hidden from the screen reader because “black star, black star...” is noise; the sentence next to them carries the meaning. Never put aria-hidden="true" on something that can receive focus, like a button or link: the keyboard can still reach it, but the screen reader says nothing.
Focus Management Basics
Focus is the place where keyboard input goes. When your script opens or closes something, think about where focus should go next. Two simple rules cover most cases:
- When you open a new area, move focus into it, often to its heading.
- When you close it, send focus back to the button that opened it.
A heading cannot normally receive focus. Adding tabindex="-1" lets your script focus it with .focus(), without adding it to the Tab order. Never use a positive tabindex like tabindex="5": it breaks the natural order of the page.
<button type="button" id="open-form">Request a callback from the office</button>
<section id="form-area" hidden>
<h2 id="form-heading" tabindex="-1">Your Details</h2>
<label for="sname">Student name</label>
<input id="sname" type="text">
<button type="button" id="close-form">Close</button>
</section>
<script>
const openBtn = document.getElementById('open-form');
const area = document.getElementById('form-area');
openBtn.addEventListener('click', () => {
area.hidden = false;
document.getElementById('form-heading').focus();
});
document.getElementById('close-form').addEventListener('click', () => {
area.hidden = true;
openBtn.focus();
});
</script>
Real-Life Example: Accessible Course Tabs
Now fix Ravi’s college website. The course tabs use real buttons with tab roles. Only the selected tab is in the Tab order, and the arrow keys move between tabs. This is the keyboard pattern screen reader users expect from tabs, as described in the ARIA Authoring Practices Guide published by the W3C.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Courses at Sunrise Arts and Science College</title>
<style>
[role="tablist"] { display: flex; gap: 4px; }
[role="tab"] { padding: 8px 14px; border: 1px solid #888; background: #f2f2f2; }
[role="tab"][aria-selected="true"] { background: #1e6fd9; color: white; }
[role="tabpanel"] { border: 1px solid #888; padding: 12px; }
</style>
</head>
<body>
<h1>Our Courses</h1>
<div role="tablist" aria-label="Course streams">
<button type="button" role="tab" id="tab-sci" aria-selected="true" aria-controls="panel-sci">Science</button>
<button type="button" role="tab" id="tab-com" aria-selected="false" aria-controls="panel-com" tabindex="-1">Commerce</button>
<button type="button" role="tab" id="tab-arts" aria-selected="false" aria-controls="panel-arts" tabindex="-1">Arts</button>
</div>
<div role="tabpanel" id="panel-sci" aria-labelledby="tab-sci">
<p>B.Sc. Physics, B.Sc. Computer Science, B.Sc. Mathematics.</p>
</div>
<div role="tabpanel" id="panel-com" aria-labelledby="tab-com" hidden>
<p>B.Com General, B.Com Computer Applications.</p>
</div>
<div role="tabpanel" id="panel-arts" aria-labelledby="tab-arts" hidden>
<p>B.A. English, B.A. History, B.A. Economics.</p>
</div>
<script>
const tabs = Array.from(document.querySelectorAll('[role="tab"]'));
function selectTab(newTab) {
tabs.forEach((tab) => {
const selected = tab === newTab;
tab.setAttribute('aria-selected', String(selected));
tab.tabIndex = selected ? 0 : -1;
document.getElementById(tab.getAttribute('aria-controls')).hidden = !selected;
});
newTab.focus();
}
tabs.forEach((tab, index) => {
tab.addEventListener('click', () => selectTab(tab));
tab.addEventListener('keydown', (event) => {
if (event.key === 'ArrowRight') selectTab(tabs[(index + 1) % tabs.length]);
if (event.key === 'ArrowLeft') selectTab(tabs[(index - 1 + tabs.length) % tabs.length]);
});
});
</script>
</body>
</html>
Try these changes:
- Add a fourth tab for “Vocational” with its own panel. Remember the matching
id,aria-controlsandaria-labelledby. - Add support for the Home and End keys to jump to the first and last tab.
- Remove
tabindex="-1"from the Commerce tab and press Tab through the page. Notice the extra stop.
Testing with the Keyboard and a Screen Reader
You do not need special equipment to test. Do this for every component you build:
- Keyboard only: put the mouse away. Use Tab and Shift + Tab to move, Enter and Space to press, and arrow keys inside tabs. Can you reach and use everything? Can you always see where focus is?
- Screen reader: NVDA is free for Windows. Mac and iPhone have VoiceOver built in (Cmd + F5 on a Mac). Android phones have TalkBack in the accessibility settings. Listen: does each control say its name, its role and its state?
- Browser tools: in Chrome DevTools, the Elements panel has an Accessibility tab that shows the role and name of the selected element.
Common Mistakes
- Adding a role without the behaviour.
role="button"on a<span>makes the screen reader say “button”, but Tab skips it and Enter does nothing. Fix: use a real<button>. - Forgetting to update the state. Setting
aria-expanded="false"once and never changing it lies to the user. Fix: update it in the same function that shows or hides the panel. - aria-label on a plain div or span. Many screen readers ignore names on elements that have no role. Fix: put names on interactive elements, landmarks or elements with a proper role.
- Creating the live region too late. Fix: keep an empty
role="status"element in the HTML from the start.
You saw the first mistake in action at the start of this lesson: the fake button that Tab skipped.
Practice Task: An Accessible Hostel FAQ
The hostel FAQ uses a clickable <div>. Rebuild it as a proper disclosure. Your finished version should:
- Use a real
<button>inside an<h3> - Have
aria-expandedandaria-controlson the button - Hide the answer with
hiddenat the start - Announce “Answer shown” or “Answer hidden” through a
role="status"element - Work fully with Tab and Enter
<h2>Hostel FAQ</h2>
<!-- TODO 1: Turn this div into a real button inside an h3 -->
<!-- TODO 2: Add aria-expanded="false" and aria-controls pointing to the answer -->
<div class="question">Can parents visit on weekends?</div>
<!-- TODO 3: Give the answer an id and hide it at the start -->
<div class="answer">
<p>Yes. Parents can visit on Saturday and Sunday from 10 AM to 5 PM.</p>
</div>
<!-- TODO 4: Add an empty status message with role="status" -->
<script>
// TODO 5: On click, flip aria-expanded, show or hide the answer,
// and write "Answer shown" or "Answer hidden" in the status message.
</script>
Show one possible answer (try it yourself first!)
<h2>Hostel FAQ</h2>
<h3>
<button type="button" id="q1" aria-expanded="false" aria-controls="a1">
Can parents visit on weekends?
</button>
</h3>
<div id="a1" hidden>
<p>Yes. Parents can visit on Saturday and Sunday from 10 AM to 5 PM.</p>
</div>
<p id="faq-status" role="status"></p>
<script>
const question = document.getElementById('q1');
const answerBox = document.getElementById('a1');
const faqStatus = document.getElementById('faq-status');
question.addEventListener('click', () => {
const isOpen = question.getAttribute('aria-expanded') === 'true';
question.setAttribute('aria-expanded', String(!isOpen));
answerBox.hidden = isOpen;
faqStatus.textContent = isOpen ? 'Answer hidden' : 'Answer shown';
});
</script>
Key Takeaways
- The first rule of ARIA: use native elements like
<button>and<details>before reaching for ARIA. - ARIA changes what assistive technology announces. It never adds keyboard behaviour, so you must add that yourself.
- Give every control a name with visible text,
aria-labelledbyoraria-label, and extra help witharia-describedby. - Keep states like
aria-expandedandaria-selectedin sync with what is on screen. - Use
role="status"for messages like “Seat booked”, and move focus sensibly when content opens and closes. - Test every component with the keyboard alone and with a real screen reader.