To announce infinite-scroll loads, render an empty role="status" region when the page loads, set aria-busy on the list while fetching, and after each load write one debounced, polite message such as "20 more results loaded, 60 in total" — never move focus on a scroll-triggered load.

Problem / Scenario Context

A job board uses an IntersectionObserver sentinel to append twenty listings at a time as the user nears the bottom, following infinite scroll & pagination. A blind user reports that the list "ends" at twenty jobs. In reality, their screen reader's virtual cursor reached the end of the list, the page scrolled far enough to cross the sentinel, more jobs were appended — and nothing told them. They moved on to the footer, never knowing there were 400 more listings.

The load worked; the communication did not. The Accessible Observer-Driven Interfaces topic lists the requirements; this page implements the announcement.

Mechanics Explanation

Screen readers track changes to the accessibility tree, but they only speak changes inside live regions — elements with aria-live, or roles that imply it, such as status (polite) and alert (assertive). When the text content of a live region changes, the screen reader queues an announcement.

Three browser-and-screen-reader behaviours decide whether an announcement actually happens:

  1. The region must already exist. A region inserted into the DOM together with its text is frequently not announced, because the screen reader has not yet registered it as live.
  2. The text must change. Writing the same string twice produces nothing. Clearing the region and then writing on the next frame forces a re-announcement.
  3. Politeness matters. polite waits for the user to pause; assertive interrupts. Loaded results are never urgent.

aria-busy="true" on the list tells assistive technology that the region is being updated and may be incomplete; some screen readers defer reading its changes until it becomes false.

One Load, Announced OnceA timeline of a single scroll-triggered load. The sentinel crosses and the list is marked busy. The fetch runs for a few hundred milliseconds. Items are appended and the list is marked not busy. After a short debounce the status region is cleared and then the message is written, and the screen reader announces it at the next pause.Scroll-triggered load of 20 itemslistaria-busy=truearia-busy=falsenetworkfetch page 3status regiondebounce 380 ms"20 more loaded"0ms200ms400ms600ms800ms1000ms1200msitems appended

Comparison Table: Announcement Strategies

Strategy Announced reliably? Interrupts reading? Problem
No announcement no no users never learn content arrived
role="alert" per load yes yes interrupts on every load
Live region created with the message often not no region not registered yet
Pre-rendered role="status", debounced yes no
Move focus to first new item yes, by focus yes steals the reading position
role="feed" with aria-busy only partially no depends on screen reader support

Minimal Reproducible Example

TypeScript
// Silent: the region is created at the same moment as its text.
function onLoaded(count: number): void {
  const msg = document.createElement('div');
  msg.setAttribute('aria-live', 'polite');
  msg.textContent = `${count} more jobs loaded`;
  document.body.append(msg);
}

With VoiceOver or NVDA running, trigger a load: in many combinations nothing is spoken, and repeated loads pile up orphaned divs in the DOM.

Production-Safe Solution

TypeScript
interface FeedAnnouncerOptions {
  list: HTMLElement;
  itemLabel: { one: string; other: string };   // 'job', 'jobs'
  debounceMs?: number;
}

export function createFeedAnnouncer({ list, itemLabel, debounceMs = 400 }: FeedAnnouncerOptions) {
  // 1. The region exists from the start, outside any content that re-renders.
  const region = document.createElement('div');
  region.setAttribute('role', 'status');
  region.className = 'visually-hidden';
  list.insertAdjacentElement('afterend', region);

  let pendingNew = 0;
  let timer = 0;

  return {
    begin(): void {
      list.setAttribute('aria-busy', 'true');
    },
    loaded(newCount: number, total: number): void {
      list.setAttribute('aria-busy', 'false');
      pendingNew += newCount;
      clearTimeout(timer);
      // 2. Debounce: a fast fling that triggers three loads yields one message.
      timer = window.setTimeout(() => {
        const noun = pendingNew === 1 ? itemLabel.one : itemLabel.other;
        const msg = pendingNew === 0
          ? `No more ${itemLabel.other}.`
          : `${pendingNew} more ${noun} loaded, ${total} in total.`;
        pendingNew = 0;
        // 3. Clear, then write next frame, so identical messages are re-announced.
        region.textContent = '';
        requestAnimationFrame(() => { region.textContent = msg; });
      }, debounceMs);
    },
    destroy(): void { clearTimeout(timer); region.remove(); },
  };
}
TypeScript
// Wiring it to the sentinel observer
const announcer = createFeedAnnouncer({ list, itemLabel: { one: 'job', other: 'jobs' } });

const io = new IntersectionObserver(async ([e]) => {
  if (!e.isIntersecting || loading) return;
  loading = true;
  announcer.begin();
  const jobs = await fetchPage(++page);
  renderJobs(list, jobs);
  announcer.loaded(jobs.length, list.children.length);
  loading = false;
  if (jobs.length === 0) io.disconnect();
}, { rootMargin: '500px 0px' });
io.observe(sentinel);
CSS
.visually-hidden {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
}

The region is visually hidden but present in the accessibility tree. The end-of-list message matters as much as the load messages: without it, a user cannot tell "nothing more" from "still loading".

Announcer LifecycleFour boxes. On page load the status region is rendered empty. When a load begins, the list is marked busy. When it completes, the list is marked not busy and the new count is added to a pending total. After a debounce, the region is cleared and the combined message is written, so one message covers several quick loads.Region renderedempty role=status atstartbegin()aria-busy=true on thelistloaded(n, total)busy false; add topendingDebounced writeclear, then one message

Choosing What to Say

The message should answer the two questions a user has after a load: did anything happen? and how much is there now? A few phrasing rules keep it useful:

  • Lead with the change, then the total: "20 more jobs loaded, 60 in total." Screen-reader users often stop listening after the first phrase.
  • Use the domain noun, not "items" or "results", when you know it.
  • Say when the end is reached: "No more jobs." Otherwise silence is ambiguous.
  • Report failures politely too: "Could not load more jobs. Use the Load more button to try again." — and make sure that button exists.
  • Localise with plural rules, via Intl.PluralRules, rather than appending an "s".

For filterable lists, the same region can announce filter results ("12 jobs match Remote"), which keeps one channel for all list changes instead of competing regions.

Edge Cases

Initial page load. Do not announce the first page of results; it is part of the page the user just opened, and the screen reader will read it naturally. Start announcing from the second load.

Very fast loads. When results come from cache, the fetch can complete within a frame of begin(). Setting and clearing aria-busy that quickly is harmless, and the debounce still produces one message.

Loads the user did not cause. Background refreshes that insert new items at the top ("3 new posts") are content changes the user did not ask for. Announce them politely but do not insert them automatically — show a "Show 3 new posts" button, which is both accessible and less disorienting for everyone.

Multiple feeds on one page. Give each feed its own region only if they are independent and both visible; otherwise route all announcements through one region and include the feed name in the message ("Comments: 10 more loaded").

Which Changes to AnnounceA grid of list changes and whether each should be announced. The initial page of results is not announced. Scroll-triggered loads are announced politely with counts. Reaching the end is announced. Failed loads are announced with a retry hint. New items arriving from a background refresh are announced but not inserted until the user asks.Announce?MessageInitial resultsnoread naturallyScroll-triggered loadyes, polite20 more loaded, 60 totalEnd of datayes, politeNo more jobsLoad failedyes, politecould not load; retry buttonBackground refreshyes, polite3 new posts, button to show

Verification Steps

  • Run VoiceOver (macOS/iOS), NVDA (Windows) and TalkBack (Android) and trigger a load by reading to the end of the list; each should speak the message once.
  • Trigger three loads quickly by scrolling fast and confirm a single combined announcement.
  • Check the Accessibility tree in DevTools: exactly one status region exists, and it persists across loads.
  • Reach the end of the data and confirm the "no more" message.
  • Simulate a failed fetch and confirm the failure is announced and a retry control is reachable.

Common Mistakes to Avoid

  • Creating the live region on demand. It must exist before the text changes.
  • Using role="alert". It interrupts, and repeated loads make the page exhausting to use.
  • Announcing every item. Twenty announcements per load bury the user; announce counts.
  • Moving focus on scroll-triggered loads. It yanks the reading position; only move focus when a button requested the load.
  • Placing the region inside a list that re-renders. Frameworks may replace the node, which resets it and drops messages.

FAQ

Why clear the region before writing the new message?

Screen readers announce changes. If two consecutive loads both produce "20 more jobs loaded", the second write is not a change and may be ignored. Clearing and writing on the next frame guarantees a change.

Is aria-busy necessary if I have a live region?

It is not strictly required, but it helps. While busy, some screen readers hold off reading partial updates to the list, and the attribute gives automated tests a clear signal for when the list is stable.

Where should the status region live in the DOM?

Near the list it describes, but outside any component subtree that re-renders. An application-level region that persists across navigation is the most reliable.

Does a visually hidden region count as hidden to screen readers?

No. The visually-hidden pattern keeps the element in the accessibility tree. Using display: none or visibility: hidden would hide it from screen readers too, and nothing would be announced.

Should I also show a visible message?

Often yes — a brief "20 more jobs loaded" toast or a persistent count helps sighted users with cognitive disabilities as well. If the visible message is itself a live region, do not also write to a hidden one, or the message is announced twice.


↑ Back to Accessible Observer-Driven Interfaces