Module 4 left us with a static foundation: semantic HTML, forms with label, ARIA as declarative semantics, and styles with visible focus. Remember that the catalog filter dropdown for Cursalia was left "declared but without behavior": it had its role, its accessible name, and its ARIA states ready to go, but pressing it did nothing. In this lesson we bring it to life with JavaScript. You will learn to build interactive components (widgets) that not only work with a mouse, but also expose their state to assistive technologies and manage focus correctly. The golden rule we will repeat until you are sick of it: every time the DOM changes, the ARIA state must change at the same time.
Contents
- What a widget is and why ARIA without JavaScript is not enough
- The ARIA Authoring Practices Guide (APG) as a catalog of patterns
- Disclosure pattern: the catalog filter comes to life
- Accordion pattern: Cursalia's course outline and FAQ
- Tabs pattern: the student dashboard tabs
- Modal Dialog pattern: enrollment
- The "DOM + ARIA state at the same time" cycle
- Common mistakes and tips
- Exercises
- Conclusion
- What a widget is and why ARIA without JavaScript is not enough
A widget is a composite interface component that the browser does not offer natively: an accordion, tabs, a combobox, a modal dialog. In Module 4 we learned that ARIA describes the role, name, and state of an element, but ARIA adds no behavior. An aria-expanded="false" does not make anything collapse; it only announces that something is collapsed. It is up to us, with JavaScript, to:
- React to user interaction (click, keyboard).
- Change the DOM (show/hide, select).
- Synchronize the ARIA state so that Marta (using NVDA) hears the change.
- Manage focus so that Lucía (who navigates with the keyboard) does not get lost.
Without that synchronization, we have a component that "works" visually but lies to assistive technologies. A button that expands a panel but keeps aria-expanded="false" is worse than having no ARIA at all: it promises false information.
- The ARIA Authoring Practices Guide (APG) as a catalog of patterns
You do not have to invent how an accordion should behave. The ARIA Authoring Practices Guide (APG) is the official W3C guide that documents, for each widget pattern, three things:
| What the APG documents | Example (Tabs pattern) |
|---|---|
| ARIA roles, states, and properties | role="tablist", role="tab", role="tabpanel", aria-selected, aria-controls |
| Expected keyboard interaction | Arrow keys to switch tabs, Home/End, Tab leaves the group |
| Focus management | Roving tabindex over the tabs |
The APG is your contract of behavior. When a screen reader user encounters a role="tablist", their software announces "tab group" and expects to be able to move with the arrow keys. If you implement something else, you break that expectation. That is why in this lesson we follow the APG to the letter.
Division of labor in this module. Here (05-01) we build the patterns and manage the dynamic ARIA states and the focus of each component. The exhaustive key conventions and the focus mechanics (tabindex, roving tabindex, focus trap, focus order) are detailed in 05-02 (Keyboard Accessibility). When we say "handle the arrow keys" or "trap the focus" here, the concrete technique is in 05-02. The dynamic content of SPAs (routing, page-change announcements) belongs to Module 7.
- Disclosure pattern: the catalog filter comes to life
A disclosure is the simplest pattern: a button that shows or hides a region. It is exactly what the Cursalia filter dropdown needs. Let's pick up the HTML we left ready in Module 4:
<!-- Static, exactly as it was left in Module 4: declared but without behavior -->
<button id="filter-btn" aria-expanded="false" aria-controls="filter-panel">
Filter courses
</button>
<div id="filter-panel" hidden>
<fieldset>
<legend>Category</legend>
<label><input type="checkbox" name="cat" value="dev"> Development</label>
<label><input type="checkbox" name="cat" value="design"> Design</label>
</fieldset>
</div>Notice three details that were already in place: it is a real <button> (not a <div>), it has aria-expanded, and it has aria-controls pointing to the panel by id. Now the JavaScript:
const btn = document.getElementById('filter-btn');
const panel = document.getElementById('filter-panel');
btn.addEventListener('click', () => {
// 1. Read the CURRENT state from ARIA (the source of truth)
const isOpen = btn.getAttribute('aria-expanded') === 'true';
// 2. Change the DOM and the ARIA state AT THE SAME TIME
btn.setAttribute('aria-expanded', String(!isOpen));
panel.hidden = isOpen; // if it was open, we hide it now
// 3. Focus management: when OPENING, move focus to the panel's first control
if (!isOpen) {
panel.querySelector('input, button, a')?.focus();
}
});Why panel.hidden and not display:none via CSS? The hidden attribute removes the content from the accessibility tree AND from the tab order at the same time. Marta does not hear it and Lucía cannot tab to an invisible checkbox. It is the most robust way to hide something.
On focus management here, a nuance is worth making: in a simple disclosure (a navigation menu, a "read more") you are not required to move focus on open; many implementations leave focus on the button. But in a filter panel with a form, moving focus to the first control greatly improves the keyboard experience. On close, if focus was inside the panel, you must return it to the button so as not to leave focus orphaned:
// On close via Escape or on re-press, return focus to the trigger
function closePanel() {
btn.setAttribute('aria-expanded', 'false');
panel.hidden = true;
btn.focus(); // focus returns to a predictable place
}
- Accordion pattern: Cursalia's course outline and FAQ
The accordion is a stack of disclosures: several headings that expand/collapse their section. It is the pattern for the course outline and the FAQ block. The key APG detail is that each header is a <button> wrapped in a heading (<h3>):
<h3>
<button aria-expanded="false" aria-controls="sec-1" id="header-1">
Module 1: Introduction
</button>
</h3>
<div id="sec-1" role="region" aria-labelledby="header-1" hidden>
<p>Content of module 1…</p>
</div>The <h3> lets Marta jump from section to section using NVDA's headings key. The role="region" + aria-labelledby gives each panel a name. The JS logic is identical to the disclosure, generalized to several items:
document.querySelectorAll('.accordion button').forEach((header) => {
header.addEventListener('click', () => {
const isOpen = header.getAttribute('aria-expanded') === 'true';
header.setAttribute('aria-expanded', String(!isOpen));
document.getElementById(header.getAttribute('aria-controls')).hidden = isOpen;
// In an accordion we do NOT move focus to the panel: the user keeps reading
// in order. Focus stays on the header (APG behavior).
});
});An important design decision: allow several panels open at once, or only one? The APG permits both. For a course outline, leave several open (the user compares modules); for a long FAQ, closing the others when one opens reduces noise. If you close the others, remember to update their aria-expanded to false — that is the most common mistake.
- Tabs pattern: the student dashboard tabs
The tabs of the student dashboard (for example: "My courses", "Progress", "Certificates") use three coordinated roles. Only the active panel is visible; the others carry hidden.
<div role="tablist" aria-label="Student dashboard">
<button role="tab" id="t1" aria-selected="true" aria-controls="p1">My courses</button>
<button role="tab" id="t2" aria-selected="false" aria-controls="p2" tabindex="-1">Progress</button>
</div>
<div role="tabpanel" id="p1" aria-labelledby="t1" tabindex="0">…</div>
<div role="tabpanel" id="p2" aria-labelledby="t2" tabindex="0" hidden>…</div>Notice the tabindex="-1" on the unselected tab: this is the roving tabindex, the technique that makes Tab enter the group with a single stop and the arrow keys move between tabs. That mechanic is explained in detail in 05-02; here we focus on the dynamic ARIA state:
const tabs = document.querySelectorAll('[role="tab"]');
function activate(selectedTab) {
tabs.forEach((tab) => {
const isSelected = tab === selectedTab;
// ARIA state + DOM kept in sync for EACH tab
tab.setAttribute('aria-selected', String(isSelected));
tab.tabIndex = isSelected ? 0 : -1; // roving tabindex
document.getElementById(tab.getAttribute('aria-controls')).hidden = !isSelected;
});
selectedTab.focus(); // focus follows the active tab
}
tabs.forEach((tab) => tab.addEventListener('click', () => activate(tab)));
// Arrow-key handling (ArrowLeft/Right, Home, End) is implemented per 05-02.The mental pattern is the same as always: we iterate over all the elements in the group and, for each one, we set its aria-selected, its tabindex, and the visibility of its panel to be consistent with each other.
- Modal Dialog pattern: enrollment
The enrollment modal dialog is the most demanding pattern because it must trap the user inside while it is open. The ideal choice today is the native <dialog> element with showModal(), which gives you a lot for free (initial focus, Esc to close, an inert background). When you cannot use it, you replicate its behavior with ARIA:
<div role="dialog" aria-modal="true" aria-labelledby="modal-title" id="modal" hidden>
<h2 id="modal-title">Course enrollment</h2>
<form>…</form>
<button id="close-modal">Cancel</button>
</div>role="dialog" + aria-modal="true" tells the screen reader that the rest of the page is inert: Marta should not be able to leave the dialog by mistake. aria-labelledby names the dialog with its title. The JS skeleton for opening and closing:
let trigger = null; // remember WHO opened the modal
function openModal(openingButton) {
trigger = openingButton;
modal.hidden = false;
// Initial focus: on the first field or on the dialog title
modal.querySelector('input, button, [tabindex]')?.focus();
// Activate the FOCUS TRAP (technique detailed in 05-02)
}
function closeModal() {
modal.hidden = true;
// ESSENTIAL focus management: return focus to whoever opened it
trigger?.focus();
}Two critical things that define an accessible modal:
- Focus trap: while the modal is open,
TabandShift+Tabmust cycle inside the dialog, without escaping to the background content. The full technique (detecting the first and last focusable element and redirecting focus) is implemented in 05-02. - Return focus on close: we store the element that opened the modal (
trigger) and return focus to it on close. Otherwise, Lucía closes the modal and her focus drops to the top of the page: total disorientation.
- The "DOM + ARIA state at the same time" cycle
All the patterns above share the same skeleton. Internalize it as a single cycle:
flowchart LR A[Interaction<br/>click or key] --> B[Read current state<br/>from ARIA] B --> C[Change the DOM<br/>show/hide/select] C --> D[Sync ARIA<br/>expanded/selected/hidden] D --> E[Manage focus<br/>move or return]
Phase 4 (focus) and phase 3 (ARIA) are the ones almost everyone forgets. A component that only does the "change the DOM" phase is a component broken for accessibility even if it looks perfect.
Common Mistakes and Tips
- Using a
<div>withonclickinstead of a<button>. Adivis neither focusable nor operable by keyboard; you would have to addtabindex,role="button", and handleEnter/Spaceby hand. Always use the native element: it gives you all of that for free. - Changing the DOM and forgetting the ARIA. The classic case: showing the panel but leaving
aria-expanded="false". Marta hears "collapsed" while a sighted user sees the panel open. - Not returning focus when closing a modal or panel. It leaves focus orphaned at the top of the document. Always store the trigger.
- Hiding with
visibility:hiddenmisapplied or withopacity:0.opacity:0leaves the element focusable and in the accessibility tree: Lucía tabs to invisible buttons. Usehiddenordisplay:none. - Reinventing the keys. Do not invent that the accordion opens with the right arrow key when the APG says it opens with
Enter/Space. Follow the pattern: consistency is accessibility. - Tip: before coding a widget, look up its pattern in the APG and copy its table of roles and keys. It is your specification.
Exercises
Exercise 1. The following disclosure toggles the panel but has an accessibility bug. Identify it and fix it.
Exercise 2. Write the activate(tab) function for a tab group that, besides showing the correct panel, keeps all the ARIA states consistent. List which attributes you must touch on each tab.
Exercise 3. An enrollment modal opens with correct focus but, when "Cancel" is pressed, focus jumps to the top of the page. What is missing and how do you fix it?
Solutions
Solution 1. The bug is that aria-expanded is never updated. The panel shows/hides visually, but the screen reader keeps announcing the old state. Fix by syncing both:
btn.addEventListener('click', () => {
const isOpen = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!isOpen));
panel.hidden = isOpen;
});Solution 2. For each tab you must sync three things: aria-selected (true only on the active one), tabindex (0 on the active one, -1 on the rest: roving tabindex), and the hidden of its associated panel. Also, move focus to the activated tab.
function activate(selectedTab) {
document.querySelectorAll('[role="tab"]').forEach((tab) => {
const isSelected = tab === selectedTab;
tab.setAttribute('aria-selected', String(isSelected));
tab.tabIndex = isSelected ? 0 : -1;
document.getElementById(tab.getAttribute('aria-controls')).hidden = !isSelected;
});
selectedTab.focus();
}Solution 3. What is missing is storing the trigger and returning focus to it on close. Store the button that opened the modal in a variable and, in closeModal(), call trigger.focus(). Without that, when the modal is hidden focus drops to the <body> and from there to the top of the document.
Conclusion
You now know how to build Cursalia's key widgets following the ARIA Authoring Practices Guide: disclosure (the filter we left pending in Module 4), accordion, tabs, and modal dialog. The central idea is the cycle "change the DOM and the ARIA state at the same time, and manage focus". Along the way, however, we have kept deferring the keyboard mechanics: which keys move between tabs, how roving tabindex works, exactly how the modal's focus trap is implemented, and how to avoid keyboard traps. All of that is the foundation on which any widget rests, and it is exactly what we will cover in the next lesson, 05-02: Keyboard Accessibility, with Lucía and Marta as the protagonists.
Web Accessibility Course
Module 1: Introduction to Web Accessibility
- What Is Web Accessibility?
- The Importance of Web Accessibility
- Overview of Accessibility Laws and Standards
- Introduction to WCAG
Module 2: Understanding Disabilities and Assistive Technologies
Module 3: Principles of Accessible Design
- Perceivable: Making Content Available to the Senses
- Operable: User Interface and Navigation
- Understandable: Information and Operation
- Robust: Compatibility with Current and Future Technologies
Module 4: Implementing Accessibility in HTML and CSS
Module 5: Accessibility in JavaScript and Multimedia
- Creating Accessible JavaScript Widgets
- Keyboard Accessibility
- Accessible Video and Audio Content
- Providing Text Alternatives for Images
Module 6: Accessibility Testing and Evaluation
- Manual Testing Techniques
- Automated Testing Tools
- User Testing with Assistive Technologies
- Interpreting Accessibility Reports
