Module 6 ended with a finding that left a thorn in our side: Marta's P1. When she confirmed her enrollment, Cursalia showed a nice green message reading "Enrollment complete!"… that Marta, using NVDA, never heard. The message appeared on screen without reloading the page, and a screen reader does not "look" at the screen: it only knows what the browser communicates to it through the accessibility tree. If you simply insert content into the DOM, anyone who can't see it is left in the dark. In this lesson we solve that problem—and its whole family—with two tools: live regions (aria-live) to announce changes, and focus management to take the user to where the action happens. With these, we make the leap from static pages to content that changes without a reload, which is where the modern web lives.

Contents

  1. The problem: the DOM changes and nobody notices
  2. Live regions (aria-live) in depth
  3. polite versus assertive: the courtesy of the announcement
  4. Implicit roles: status, alert, and log
  5. Fine-tuning the announcement: aria-atomic and aria-relevant
  6. Applied to Cursalia: filter results, quiz, toasts, and enrollment
  7. Focus management in dynamic changes
  8. Asynchronous content: "load more", aria-busy, and loading states
  9. Tricky patterns: tooltips, deferred disclosure, and drag-and-drop
  10. Common mistakes and tips
  11. Exercises
  12. Conclusion

  1. The problem: the DOM changes and nobody notices

A sighted user detects any visual change at a glance: a toast pops up, the number of results changes, an error turns red. But assistive technologies, by default, only announce what the user navigates to or where the focus is. A change that happens away from the focus—in another part of the page—goes completely unnoticed by Marta (NVDA) or by Sofía, who is looking through a magnifier at 300% somewhere else on the screen.

There are two ways to communicate a dynamic change to assistive technologies, and you have to choose the right one:

Technique What it does When to use it
Live region Announces the change without moving the focus The user stays on their task; you just need to inform them (results, toasts, status)
Focus management Takes the focus to the new content The change demands immediate attention or interaction (opening a modal, jumping to an error)

The mental rule: does the user need to go somewhere, or do they just need to be informed? If they just need to be informed, use a live region. If they need to act on the new content, move the focus. Confusing the two is the root of half of all dynamic-content problems.

  1. Live regions (aria-live) in depth

A live region is a DOM element marked so that, when its content changes, the assistive technology announces it automatically even if the focus is elsewhere. In Modules 4 and 5 we mentioned it in passing; here we open it right up. It is declared with the aria-live attribute:

<!-- The container exists from the start, empty. -->
<div id="filter-announcement" aria-live="polite"></div>

When JavaScript writes inside that div, NVDA will read it. The mechanism is simple, but it has a golden rule that almost everyone breaks:

The live region must exist in the DOM before its content changes. The screen reader "watches" the regions it already knows about. If you create the <div aria-live> and put text into it in the same instant, many readers don't manage to register it in time and announce nothing.

In other words: first you render the empty container (when the page or view loads), and then, at a later moment, you write inside it. This subtlety—a region created too late—is the number-one mistake with aria-live, and we'll see it again in section 10.

  1. polite versus assertive: the courtesy of the announcement

aria-live accepts two useful values (off is the third and means "don't announce"):

Value Behavior Use it for
polite Waits until the reader finishes what it's saying, and then announces The vast majority of cases: results, statuses, confirmations
assertive Interrupts immediately whatever the reader is saying Emergencies only: critical errors, time-running-out warnings, data loss

assertive is like shouting in a conversation: sometimes necessary, almost always rude. If you overuse it, you constantly interrupt Marta and stop her from hearing what she was reading. Default to polite. Reserve assertive for what truly cannot wait.

<!-- Good: a search result is not an emergency -->
<div aria-live="polite" id="results-count"></div>

<!-- Good: a session about to expire DOES interrupt -->
<div aria-live="assertive" id="session-warning"></div>

<!-- Bad: interrupting Marta every time a trivial counter changes -->
<div aria-live="assertive" id="visit-counter"></div>

  1. Implicit roles: status, alert, and log

Instead of aria-live "by hand", it's often better to use a role that already has the live-region behavior built in. They are more semantic and more robust across browsers and readers:

Role Equivalent to Typical use in Cursalia
role="status" aria-live="polite" + aria-atomic="true" "3 courses found", "Saved", "Loading…"
role="alert" aria-live="assertive" + aria-atomic="true" Enrollment form error, expired session
role="log" aria-live="polite" (new messages at the end) Forum chat, activity log in the dashboard
<!-- Catalog results counter: status (polite) -->
<p role="status" id="results-count"></p>

<!-- Critical enrollment error: alert (assertive) -->
<p role="alert" id="enrollment-error"></p>

The advantage of role="status" and role="alert" is that they already come with aria-atomic="true" and clear semantics. role="log" is different: it is meant for a sequence of messages that accumulate (Cursalia's forum chat), where only the new message arriving at the end matters, not re-reading the whole history.

  1. Fine-tuning the announcement: aria-atomic and aria-relevant

Two attributes let you control how much and what the region announces when it changes:

  • aria-atomic: if true, the reader announces the entire region every time something changes inside it. If false (the default with aria-live), it announces only the node that changed.
<!-- aria-atomic="true": we want the whole sentence read out with meaning -->
<p aria-live="polite" aria-atomic="true" id="saved-status">
  Progress saved at <span id="time">2:32 PM</span>
</p>

If, on updating only the time <span>, we had aria-atomic="false", Marta would hear a bare "2:35 PM" with no context. With aria-atomic="true" she hears the full sentence: "Progress saved at 2:35 PM". That's why status and alert enable it by default: you almost always want the message with its context.

  • aria-relevant: indicates which types of change trigger an announcement (additions, removals, text, all). Its default value (additions text) works for most cases: it announces what is added and text changes, but not what is removed. You'll rarely need to touch it; if a toast disappears, you usually don't want to announce its removal.

Tip: in practice, role="status" and role="alert" with pre-existing containers cover 90% of your needs. Reserve aria-atomic/aria-relevant for specific cases where the default announcement doesn't say what you want.

  1. Applied to Cursalia: filter results, quiz, toasts, and enrollment

Number of results when filtering the catalog

When Marta applies the filter we built in Module 5, the course list updates without reloading. A sighted user sees the grid change; Marta needs to hear how many results there are:

<!-- Present from page load, empty -->
<p role="status" id="results-count"></p>
function applyFilter(courses) {
  renderGrid(courses);
  // We write AFTER, into a container that already existed:
  document.getElementById('results-count').textContent =
    `${courses.length} courses found`;
}

Now, when filtering, NVDA announces "12 courses found" without Marta losing her position on the page.

Quiz feedback

When answering a quiz question, the feedback ("Correct" / "Incorrect, the answer was…") appears dynamically. Since it is important information but not an emergency, role="status" (polite) fits:

<p role="status" id="question-feedback"></p>
function grade(isCorrect, explanation) {
  const fb = document.getElementById('question-feedback');
  fb.textContent = isCorrect
    ? 'Correct. Well done!'
    : `Incorrect. ${explanation}`;
}

Notifications / toasts

Toasts are the classic live-region case. Never move focus to them (it would interrupt the user's task): just announce them. An informational toast goes in a polite region; an error one, in assertive:

<!-- Two fixed regions, one for each urgency level -->
<div role="status" id="toast-info"></div>
<div role="alert"  id="toast-error"></div>
function showToast(message, type = 'info') {
  const region = type === 'error'
    ? document.getElementById('toast-error')
    : document.getElementById('toast-info');
  region.textContent = message; // announced automatically
  // (the toast's visual disappearance after 5 s doesn't need to be announced)
}

The enrollment success message: Marta's P1, solved

And here we close the thorn from Module 6. The problem was that the success message was inserted into the DOM with no live region at all. The solution is exactly the same technique:

<!-- Region present on the enrollment flow page, empty to start -->
<div role="status" id="enrollment-result"></div>
async function confirmEnrollment(courseId) {
  await api.enroll(courseId);
  // The message is now announced to Marta:
  document.getElementById('enrollment-result').textContent =
    'Enrollment complete! You now have access to the course.';
}

With three lines, Marta goes from not knowing whether her enrollment worked to hearing the confirmation. That is the power—and the simplicity—of a well-placed live region.

  1. Focus management in dynamic changes

Live regions announce without moving the focus. But sometimes the user needs to go to the new content. The key question: where do I send the focus when I insert or remove content?

  • When inserting content the user must interact with (opening a modal, expanding a form, showing an error panel to correct): move the focus to the new content. In a validation-error panel, move the focus to the error summary or to the first invalid field.
  • When removing the element that had the focus (deleting a row from "my courses", closing a card): if you let the focus fall to the <body>, the keyboard user is lost. Move the focus to a predictable and related place: the next item in the list, the parent container, or the button that triggered the action.
// Delete a course from "my courses" and don't lose the focus
function deleteCourse(row) {
  const next = row.nextElementSibling || row.previousElementSibling;
  row.remove();
  // Send the focus somewhere meaningful, never leave it orphaned
  (next ?? document.getElementById('my-courses-list')).focus();
}

Recall from Module 5 that, to be able to programmatically focus a container that is not natively focusable (a <div>, an <h2>, a <ul>), it needs tabindex="-1": focusable by code but outside the natural tab order.

  1. Asynchronous content: "load more", aria-busy, and loading states

Content that arrives over the network (fetch) poses two challenges: signaling that it's loading and managing the focus when it arrives.

Loading states with aria-busy

While a region is updating, aria-busy="true" tells the assistive technology "this isn't ready yet, wait". When it finishes, you set it to false:

async function loadProgress() {
  const panel = document.getElementById('progress-panel');
  panel.setAttribute('aria-busy', 'true');
  const data = await api.progress();
  renderProgress(data);
  panel.setAttribute('aria-busy', 'false'); // now it's ready
}

Combine it with a "Loading…" message in a status region so the user hears the state, not an awkward silence:

<div id="progress-panel" aria-busy="false">
  <p role="status" id="loading-status"></p>
  <!-- content -->
</div>

The "Load more" button

When Cursalia's catalog loads more courses on clicking "Load more", the keyboard user has a subtle problem: they press the button, 12 courses are added above the button… and now where is their focus? Best practices:

  1. Announce how many new items were added (a status region): "12 more courses loaded".
  2. Move the focus to the first of the new items (with tabindex="-1"), so the user continues right where the content was added, not back at the beginning.
async function loadMore() {
  const newCourses = await api.moreCourses();
  const firstNew = renderAndReturnFirst(newCourses); // returns the node, with tabindex="-1"
  document.getElementById('loading-status').textContent =
    `${newCourses.length} more courses loaded`;
  firstNew.focus(); // continuity for the keyboard
}

  1. Tricky patterns: tooltips, deferred disclosure, and drag-and-drop

Tooltips

An accessible tooltip is associated with the control via aria-describedby and must be able to show up both on focus and on hover (WCAG 1.4.13, Content on Hover or Focus: dismissable with Esc, hoverable, and persistent). Never put essential information in a tooltip alone: it is secondary content by definition.

<button aria-describedby="tip-save">Save draft</button>
<div role="tooltip" id="tip-save" hidden>
  It is saved automatically every 30 seconds.
</div>

With aria-describedby, when Marta focuses the button, NVDA reads "Save draft, button" and then the description. You don't need aria-live here: the describedby association already causes it to be read on focus.

Deferred (lazy) disclosure content

Sometimes the content of a disclosure (from Module 5) doesn't exist until it's opened: it's fetched over the network on expand. The challenge is that the live region and the focus work after the content arrives, not before:

async function openDeferredSection(btn, panel) {
  btn.setAttribute('aria-expanded', 'true');
  panel.hidden = false;
  panel.setAttribute('aria-busy', 'true');
  panel.innerHTML = '<p role="status">Loading content…</p>';
  const html = await api.section(panel.dataset.id);
  panel.innerHTML = html;          // now the content exists
  panel.setAttribute('aria-busy', 'false');
}

A note on accessible drag-and-drop

Mouse drag-and-drop is, by nature, inaccessible to anyone using the keyboard alone (Lucía) or a screen reader (Marta). WCAG 2.2 added criterion 2.5.7 (Dragging Movements, AA), which requires that any dragging functionality have an alternative that does not require dragging. To reorder Cursalia's favorite courses, drag-and-drop is not enough: you have to provide "Move up / Move down" buttons operable by keyboard and announce the position change in a live region ("Course moved to position 2 of 5"). Dragging is a visual extra, not the only path. This ties directly back to what we saw in Module 5 about operating everything by keyboard.

Common Mistakes and Tips

  • Creating the live region and filling it in the same instant. The reader doesn't manage to register it and announces nothing. Render the empty container first (when the view loads) and write into it afterward.
  • Overusing assertive. It constantly interrupts. Default to polite/status; assertive/alert only for real emergencies.
  • Inserting too much into the live region. If you dump a huge HTML block, the reader tries to read it all and gets overwhelmed. The region should contain a short message, not half the catalog.
  • Announcements that trample each other. Two assertive regions firing at once, or updating the same region three times in 200 ms: the user hears a jumble. Batch the message and write once.
  • Moving the focus when you only needed to announce (and vice versa). A toast that steals the focus interrupts the task; a modal that only "announces" but doesn't receive the focus leaves the keyboard user out. Choose based on the table in section 1.
  • Leaving the focus orphaned when removing content. If you delete the focused element, relocate the focus to a meaningful neighbor.
  • Tip: keep a single reusable status region and a single reusable alert region on the page, present from startup. It's more reliable than creating new regions on the fly.

Exercises

Exercise 1. This code tries to announce the number of filter results, but Marta hears nothing. Identify the error and fix it.

function filter(courses) {
  const region = document.createElement('div');
  region.setAttribute('aria-live', 'polite');
  region.textContent = `${courses.length} results`;
  document.body.appendChild(region);
}

Exercise 2. Classify each Cursalia message as role="status" (polite) or role="alert" (assertive) and justify it: (a) "Progress saved", (b) "Your session will expire in 60 seconds", (c) "3 courses found", (d) "Error: payment could not be processed".

Exercise 3. When "Load more" is clicked in the catalog, 12 courses are added and Lucía's focus stays on the button, which has now moved far down the page. Describe the two accessibility adjustments you would apply and why.

Solutions

Solution 1. The error is that the region is created and filled at the same moment (and it's added to the DOM already with text inside). The screen reader hadn't registered it as a live region, so it doesn't announce the change. The solution is to have the aria-live/role="status" container present and empty beforehand, and only write its textContent at the moment of the change:

<p role="status" id="results"></p>
function filter(courses) {
  document.getElementById('results').textContent = `${courses.length} results`;
}

Solution 2. (a) status: saving is a routine confirmation, not urgent. (b) alert: a session about to expire is time-sensitive and can cause data loss; interrupting is justified. (c) status: a results counter is passive information and should never interrupt. (d) alert: a payment error blocks a critical task and the user must know immediately.

Solution 3. First, announce the change with a role="status" region: "12 more courses loaded", so Marta knows the action had an effect. Second, move the focus to the first newly added course (giving it tabindex="-1" so it can be focused programmatically), so that Lucía continues navigating from the new content instead of being stranded on a button that is now off-screen. This way we combine announcement (for the reader) and focus (for the keyboard), each solving its part.

Conclusion

You've learned how to make content that changes without a reload accessible: live regions to announce (polite/assertive, status/alert/log, aria-atomic/aria-relevant) and focus management to direct the user when interaction is needed. And we've finally closed Marta's P1 from Module 6: her enrollment message is now heard. The key takeaway: always ask yourself whether the user needs to be informed or needs to go, and choose the right tool.

But so far we've talked about changes within a page. What happens when the whole application is a SPA and not even "navigation" reloads the page—instead the entire DOM is rewritten by client-side routing? The screen reader doesn't announce the new "page", the focus is left stranded, and the <title> doesn't change. That is the challenge of the next lesson, 07-02: Accessibility in Single-Page Applications (SPAs), where Cursalia's student dashboard will be our testing ground.

© Copyright 2026. All rights reserved