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.
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
<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
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>.
Step 4: Keep the footer reachable
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.
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 |
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.
// 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.
Framework Integration Patterns
A shared announcer keeps live-region handling out of every component:
// 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.
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-busyreturns tofalse - 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.
Related
- Announcing Infinite Scroll Loads with aria-live — live regions done right
- Keeping Keyboard Focus Stable When Content Loads — focus and insertion order
- Accessible Lazy Images: Alt Text and Placeholders — images that are described before they load
- Making Scroll Spy Navigation Screen-Reader Friendly — conveying the active section
- Respecting prefers-reduced-motion in Scroll Reveals — motion safety
↑ Back to Implementation Patterns for Viewport & Resize Tracking