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

  1. What a widget is and why ARIA without JavaScript is not enough
  2. The ARIA Authoring Practices Guide (APG) as a catalog of patterns
  3. Disclosure pattern: the catalog filter comes to life
  4. Accordion pattern: Cursalia's course outline and FAQ
  5. Tabs pattern: the student dashboard tabs
  6. Modal Dialog pattern: enrollment
  7. The "DOM + ARIA state at the same time" cycle
  8. Common mistakes and tips
  9. Exercises
  10. Conclusion

  1. 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.

  1. 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.

  1. 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
}

  1. 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.

  1. 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.

  1. 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:

  1. Focus trap: while the modal is open, Tab and Shift+Tab must 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.
  2. 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.

  1. 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> with onclick instead of a <button>. A div is neither focusable nor operable by keyboard; you would have to add tabindex, role="button", and handle Enter/Space by 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:hidden misapplied or with opacity:0. opacity:0 leaves the element focusable and in the accessibility tree: Lucía tabs to invisible buttons. Use hidden or display: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.

btn.addEventListener('click', () => {
  panel.hidden = !panel.hidden;
});

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.

© Copyright 2026. All rights reserved