Every observer-driven feature changes the page because of where the viewport is, not because the user pressed anything. That is convenient for pointer users and quietly hostile to screen-reader and keyboard users, who may never move the viewport the way the feature expects — so each of these features needs an accessible path designed in, not bolted on.

Concept Framing

The patterns in the Implementation Patterns section share one assumption: the user scrolls, content enters the viewport, and the page reacts. Assistive technology breaks that assumption in several ways.

  • Screen readers move a virtual cursor, not the viewport. Many screen readers do scroll the page to follow the cursor, but not always, not smoothly, and not in a way that reliably crosses a sentinel element at the bottom of a list.
  • Keyboard users move focus. Tabbing into a list scrolls the focused element into view, which triggers observers — sometimes in the middle of a list, loading content around the focused item and shifting it.
  • Content that appears silently is invisible to non-sighted users. A new page of results appended by an infinite scroll sentinel produces no announcement; the user does not know it exists.
  • Content that moves unexpectedly breaks orientation. Screen magnifier users see a small region of the page; a reveal that shifts or a list that grows above them loses their place.

The WCAG success criteria most often implicated are 2.1.1 (Keyboard), 2.4.3 (Focus Order), 4.1.3 (Status Messages), 2.2.2 (Pause, Stop, Hide) and 2.3.3 (Animation from Interactions). None of them forbid observer-driven UI; all of them require that it has an equivalent the user can operate and perceive.

What the Observer Assumes vs What Assistive Tech DoesTwo columns. The observer design assumes the user scrolls the viewport continuously, sees new content appear, and keeps their place visually. Assistive technology users move a virtual cursor or keyboard focus rather than the viewport, hear nothing when content appears unless it is announced, and lose their place if content above them grows or moves.Observer design assumesThe user scrolls the viewport continuouslyNew content is noticed because it is seenPosition is kept visually while the page growsMotion is a pleasant cueAssistive tech usersMove a virtual cursor or focus, not the viewportHear nothing unless a change is announcedLose their place when content above them movesMay need motion removed entirely

Spec / Signature Reference Table

The accessibility tools that pair with observers are mostly ARIA attributes and a few DOM APIs.

Tool Use with observers Notes
role="status" / aria-live="polite" announce "20 more results loaded" region must exist before content changes
aria-busy="true" mark a feed or list while it loads screen readers may defer reading until false
role="feed" + aria-setsize / aria-posinset infinite lists of articles lets users page through with feed shortcuts
aria-current="true" / "location" scroll spy's active link conveys state without colour alone
element.focus({ preventScroll: true }) move focus without triggering scroll observers useful when restoring focus after load
inert attribute hide off-screen virtualised content from AT removes from tab order and accessibility tree
prefers-reduced-motion drop motion from reveals see the reveal topic
<button>Load more</button> keyboard-operable alternative to a sentinel also a fallback when observers fail

Step-by-Step Implementation

This walk-through makes an observer-driven results list accessible end to end: announced loads, a keyboard alternative, stable focus and a semantic structure.

Step 1: Render a live region once, up front

HTML
<div id="results-status" role="status" aria-live="polite" class="visually-hidden"></div>
<ul id="results" aria-busy="false" aria-describedby="results-status"></ul>
<button id="load-more" type="button">Load more results</button>
<div id="sentinel" aria-hidden="true"></div>

A live region added at the same moment as its text is often not announced; it must already exist in the accessibility tree.

Step 2: Load from either the sentinel or the button

TypeScript
const status = document.getElementById('results-status')!;
const list = document.getElementById('results')!;
const button = document.getElementById('load-more') as HTMLButtonElement;
let loading = false;

async function loadNext(source: 'scroll' | 'button'): Promise<void> {
  if (loading) return;
  loading = true;
  list.setAttribute('aria-busy', 'true');
  const items = await fetchNextPage();
  const firstNew = appendItems(list, items);
  list.setAttribute('aria-busy', 'false');
  status.textContent = `${items.length} more results loaded, ${list.children.length} in total.`;
  if (source === 'button') firstNew?.focus({ preventScroll: false });  // take keyboard users to the new items
  loading = false;
}

new IntersectionObserver(([e]) => { if (e.isIntersecting) loadNext('scroll'); },
  { rootMargin: '400px' }).observe(document.getElementById('sentinel')!);
button.addEventListener('click', () => loadNext('button'));

declare function fetchNextPage(): Promise<unknown[]>;
declare function appendItems(list: HTMLElement, items: unknown[]): HTMLElement | null;

Step 3: Make new items focusable targets

appendItems should return the first new item's heading or link so focus has somewhere meaningful to go. Give headings tabindex="-1" if they are not already focusable; never move focus to a bare <li>.

An infinite list that grows every time the keyboard user approaches the end makes the footer unreachable. Stop automatic loading after a few pages and rely on the button from then on, or disable the sentinel whenever focus is inside the list.

Accessible Loading, Step by StepFour steps. Render a polite live region and a load more button before any content changes. Load the next page from either the sentinel or the button, marking the list busy. Announce the number of new items and the total. When the load came from the button, move focus to the first new item.1Live region firstrole=status exists before any text is written to it.2Two triggersThe sentinel for scrolling users, a real button for everyone else.3Announce the result"20 more results loaded, 60 in total" — politely, once per load.4Focus only on requestMove focus to new items when the button was used, never on scroll.

Threshold / Configuration Variants

The same feature can be tuned more or less aggressively, and the accessible choice usually sits at the conservative end.

Setting Aggressive Accessible default Why
Auto-load pages unlimited 2–3, then button only footer and landmarks stay reachable
Sentinel rootMargin 1000px 300–600px fewer surprise loads while reading
Live region politeness assertive polite do not interrupt reading
Announcement frequency every batch every batch, debounced 500 ms avoid chatter on fast scroll
Scroll spy updates every crossing aria-current only, no announcement state is available, not pushed
Reveal motion slide + scale fade, or none under reduced motion vestibular safety

Accessible Treatment by Observer FeatureA grid of observer-driven features against three needs. Infinite scroll needs a live announcement, a load more button and focus management. Lazy images need alt text from the start and reserved space. Scroll spy needs aria-current on the active link and no live announcement. Reveal animations need content visible without script and a reduced-motion path.Announce?Keyboard pathOther requirementInfinite scrollpolite live regionload more buttoncap auto-loadingLazy imagesnonot neededalt text, reserved spaceScroll spyno, state onlylinks are focusablearia-currentReveal effectsnonot neededvisible without JS

Edge Cases & Gotchas

Focus-triggered loads. A keyboard user tabbing through a list scrolls each focused item into view. If the sentinel sits just after the last item, focusing the last item triggers a load, and new items appear after focus — fine. If the list is bidirectional and loads upward too, items inserted above focus shift the page; the browser keeps focus but screen magnifier users lose their place. See keeping keyboard focus stable.

Virtualised lists hide content from screen readers. A windowed virtual list only renders what is near the viewport; the rest does not exist in the DOM and cannot be found by screen-reader search. Provide aria-setsize and aria-posinset so users know how long the list is, and offer a non-virtualised view or search for long datasets.

Live regions and route changes. A live region inside a component that is unmounted and remounted on navigation is recreated, and the first announcement after remount is often lost. Keep one application-level status region outside routed content.

Lazy-loaded images and alt text. Swapping data-src to src is invisible to screen readers only if the alt was present from the start. An image that gains its alt on load is announced as an unlabelled graphic until then.

Reveal effects and screen readers. Content at opacity: 0 is still in the accessibility tree and is read aloud even though it is invisible. That is usually fine — it is the correct content — but content hidden with visibility: hidden until revealed is not read, so a screen reader that has not scrolled the page skips it.

Auditing an Existing Page

Most teams inherit observer-driven features rather than design them fresh. A structured audit finds the gaps quickly, and it is worth doing feature by feature rather than page by page, because the same component usually appears everywhere.

1. Inventory the observers. In the console, wrap the constructors before the page's scripts run (a DevTools snippet or a local override) and log each observe() target with a stack trace. The list tells you every place where the viewport, not the user, changes the page.

TypeScript
// DevTools snippet: run before page scripts via a local override.
const NativeIO = window.IntersectionObserver;
window.IntersectionObserver = class extends NativeIO {
  observe(target: Element): void {
    console.log('IO observe', target, new Error().stack?.split('\n')[2]);
    super.observe(target);
  }
};

2. Classify each one. For every observer, decide whether its effect is content (something appears that the user needs), state (something changes meaning, like an active link), presentation (motion, lazy pixels) or side effect (analytics, media playback). Content effects need an announcement or explicit control; state needs ARIA attributes; presentation needs reduced-motion and no-JS paths; side effects usually need nothing.

3. Exercise without scrolling. Use the keyboard alone, then a screen reader's virtual cursor alone, and note every piece of content you cannot reach or do not hear about. Those are the failures that matter.

4. Exercise without the observer. Block the script or force the feature-detection path to fail. Everything classified as content must still be reachable — via pagination, a button, or server-rendered markup.

Classify Each Observer Before Fixing ItA grid of four effect classes and the accessibility work each requires. Content effects need an announcement and an explicit control. State effects need an ARIA attribute such as aria-current or aria-busy. Presentation effects need a no-JavaScript path and a reduced-motion path. Side effects such as analytics usually need nothing beyond not stealing focus.RequiredExampleContent appearsannounce + explicit controlinfinite scroll pageState changesARIA attributescroll spy linkPresentationno-JS and reduced-motion pathsreveal, lazy pixelsSide effectnothing, no focus theftimpression analytics

Framework Integration Patterns

A shared announcer keeps live-region handling out of every component:

TypeScript
// announcer.ts — one polite region for the whole app
let region: HTMLElement | null = null;
let timer = 0;

export function announce(message: string, delayMs = 400): void {
  region ??= Object.assign(document.body.appendChild(document.createElement('div')), {
    className: 'visually-hidden',
  });
  region.setAttribute('role', 'status');
  clearTimeout(timer);
  // Debounce so a burst of loads produces one announcement; clear first so repeats are re-read.
  timer = window.setTimeout(() => {
    region!.textContent = '';
    requestAnimationFrame(() => { region!.textContent = message; });
  }, delayMs);
}

React components call announce() from an effect after a load resolves; Vue composables and Angular services wrap the same module. Creating the region lazily at first use still works because the debounce delay gives the accessibility tree time to register it before text arrives — but creating it at app start is more reliable in older screen readers.

Testing Accessibility in CI

Manual screen-reader testing catches the experience; automated tests stop regressions. The difficulty with observer-driven UI is that most accessibility scanners only examine the page as it is at load, before any observer has fired. The fix is to drive the page into its loaded states first, then scan.

TypeScript
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('results list stays accessible after two scroll-triggered loads', async ({ page }) => {
  await page.goto('/jobs');
  const status = page.getByRole('status');
  await expect(status).toBeAttached();                    // region exists up front

  for (let i = 0; i < 2; i++) {
    await page.locator('#sentinel').scrollIntoViewIfNeeded();
    await expect(page.locator('#results')).toHaveAttribute('aria-busy', 'false');
  }
  await expect(status).toContainText('more');             // a load was announced

  const results = await new AxeBuilder({ page }).include('#results').analyze();
  expect(results.violations).toEqual([]);

  // Keyboard path: the button loads without scrolling, and focus moves to new items.
  await page.getByRole('button', { name: /load more/i }).press('Enter');
  await expect(page.locator('#results :focus')).toBeVisible();
});

Three assertions carry most of the value: the live region exists before any load, aria-busy returns to false, and the keyboard control works without scrolling. Add a reduced-motion run (test.use({ reducedMotion: 'reduce' })) for pages with reveal effects, and a JavaScript-disabled run (test.use({ javaScriptEnabled: false })) to prove content is reachable without observers at all.

Debugging Checklist

  • Check the Accessibility pane in DevTools: live regions exist before they change, and aria-busy returns to false
  • Emulate prefers-reduced-motion: reduce

FAQ

Is infinite scroll inherently inaccessible?

No, but it is easy to build inaccessibly. With announced loads, a keyboard-operable load-more control, a cap on automatic loading and a reachable footer, it can meet WCAG. A paginated alternative is still valuable for users who want to jump to a specific place.

Should I use aria-live="assertive" for new results?

No. Assertive announcements interrupt whatever the screen reader is saying. New results are not urgent; polite announcements wait for a pause.

Does role="feed" replace a live region?

It complements it. The feed role gives screen-reader users commands to move between articles and signals that more may load; the live region tells them that it did. Use aria-busy on the feed during loads.

Why not move focus to new items automatically after every load?

Because users who triggered the load by scrolling were reading something else. Stealing focus moves their screen-reader cursor away from their place. Move focus only when the user explicitly asked for more with a button.

Do observers fire when a screen reader moves its cursor?

Only if the screen reader scrolls the page to follow the cursor, which many do but not consistently. Never rely on it; always provide an explicit control for anything an observer triggers.

Does lazy loading below-the-fold content hurt screen-reader users?

It can, if the content is not in the DOM until an observer fires. A screen-reader user who uses headings or landmarks to jump around will not find sections that do not exist yet. Lazy-load expensive resources such as images and embeds, but keep the text content and headings of every section in the initial HTML.

How should a "back to top" button that appears on scroll behave?

Showing it with an observer is fine, but it must be a real button or link in the tab order once visible, with a clear accessible name, and activating it should move focus to the top of the page — the main heading or a skip target — not just scroll there. Otherwise keyboard users end up scrolled to the top with focus still at the bottom.

Are sticky headers that hide on scroll an accessibility problem?

They can hide the element that has focus. If a user tabs to a link in a hidden header, the header must reappear; listen for focusin inside it and reveal it. Also check that the sticky header does not cover the focused element in the content below it, which WCAG 2.4.11 (Focus Not Obscured) addresses; scroll-padding-top equal to the header height fixes most cases.

Do observers work with browser zoom and text resizing?

Yes. Browser zoom is a layout change, so observers recompute and deliver new entries. Test at 200% and 400% zoom anyway, because thresholds and root margins tuned in pixels can behave very differently when the layout collapses to a single narrow column.


↑ Back to Implementation Patterns for Viewport & Resize Tracking