Accessible Load More Versus Infinite Scroll
Permalink to "Accessible Load More Versus Infinite Scroll"Scroll-triggered infinite loading is the one common data-navigation pattern that cannot be made accessible without changing what it is. There is no control to reach, so there is no keyboard path; the end of the document moves further away every time the user approaches it, so the footer becomes unreachable; and the loading event has no user action attached to it, so there is nothing meaningful to announce. This page covers the replacement — an explicit load-more control — and the append behaviour that makes it work by ear.
It is the appending-model detail beneath pagination and result-set navigation, and it shares its announcement discipline with announcing page changes in paginated tables.
Spec reference
Permalink to "Spec reference"Three criteria apply and each one fails for a different reason. SC 2.1.1 Keyboard (Level A) fails because scroll-triggered loading exposes no operable control. SC 2.4.1 Bypass Blocks and, more practically, the ability to reach the footer, fail because the document end recedes indefinitely. SC 4.1.3 Status Messages (Level AA) fails because there is no defined moment to announce — the loading is a side effect of scrolling, not an action.
An explicit control satisfies all three: it is a button, so it is operable and named; it does not move the end of the document unless the user asks; and its activation is a discrete event that a status message can describe.
The DOM order matters as much as the control. Rows, then the load-more button, then the footer. A button rendered before the rows it appends leaves keyboard users tabbing backwards into content that did not exist a moment ago.
When to use — and when not to
Permalink to "When to use — and when not to"Use an appending model when the user is exploring rather than looking something up: a feed, a search result set they will skim, a log they will read forwards. Appending preserves everything they have already seen, which suits exploration.
Use numbered pagination when the user needs to return to a position, cite it, or share it. A row on “page 4” is addressable; a row 380 items into an appended list is not.
Do not combine the two. A list that appends on a button press and also has page numbers gives the user two mental models of the same data, and the announcements from each contradict the other.
Annotated code example
Permalink to "Annotated code example"<!-- Rows first, then the control, then the footer — DOM order is tab order -->
<table id="results" aria-rowcount="587"> … </table>
<button type="button" id="load-more" aria-describedby="load-status">
Load 24 more rows
</button>
<!-- SC 4.1.3: the status region, present from page load -->
<p id="load-status" role="status" class="visually-hidden"></p>
// SC 4.1.3: announce the delta and the running total, once per press
const button = document.getElementById('load-more');
const status = document.getElementById('load-status');
button.addEventListener('click', async () => {
button.setAttribute('aria-disabled', 'true'); // not [disabled] — keeps focus
status.textContent = 'Loading more rows';
const batch = await fetchNext();
appendRows(batch); // insert AFTER the last row
shown += batch.length;
table.setAttribute('aria-rowcount', String(total));
if (shown >= total) {
button.textContent = `All ${total} rows loaded`;
button.setAttribute('aria-disabled', 'true'); // stays focusable, announces state
} else {
button.removeAttribute('aria-disabled');
}
requestAnimationFrame(() => {
status.textContent = `${batch.length} more rows loaded, ${shown} of ${total} shown`;
});
});
aria-disabled rather than disabled is the load-more equivalent of the boundary-control rule from the parent guide. A disabled button loses focus the moment it is disabled, which throws the user to the top of the document mid-interaction — exactly when they are least able to work out what happened.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Assistive technology | On append | Deviation |
|---|---|---|
| NVDA + Firefox | Reads the status message; the new rows are reachable immediately | None significant |
| JAWS + Chrome | Reads the status message | May not re-read aria-rowcount until the table is re-entered |
| VoiceOver + Safari | Reads the status message after a short delay | Drops it if written in the same frame as the append |
| TalkBack + Chrome | Reads the status message | New rows enter the swipe order at the end, as expected |
Integration context
Permalink to "Integration context"An appending list interacts with virtualization in a way that surprises teams: appending 24 rows at a time to a list that also windows means the DOM contains a window of a growing array, and aria-rowcount must describe the array, not the window. Accessible virtualized list patterns covers the counting rules; the only addition here is that the total itself changes as batches arrive.
It also interacts with loading state. Each press is a load, with all the obligations in progressive loading and skeleton states: mark the region busy, announce the start, announce the settle, and handle the failure assertively.
Gotchas
Permalink to "Gotchas"The button is removed when the list is exhausted. Removing the control the user just pressed destroys focus. Keep it, relabel it, and mark it aria-disabled.
The batch is announced per row. An aria-live region wrapping the list announces all 24 new rows. One message describing the batch is what is wanted.
Scroll position jumps. Appending should never change the scroll offset of existing rows. If your implementation re-renders the whole list rather than appending, sighted users lose their place and keyboard users lose focus.
A retry that appends twice. If a failed batch is retried without clearing the partial insert, rows duplicate. Deduplicate by row id on append; a duplicated row is announced as a real row and is very hard to notice by ear.
FAQ
Permalink to "FAQ"Can infinite scroll be made accessible?
Not in its scroll-triggered form. The pattern has no control to operate, so there is nothing to reach with a keyboard, and it moves the end of the document away from the user every time they approach it, which makes the footer unreachable. Replacing the scroll trigger with a button preserves the product intent — progressive discovery of a long list — and removes every one of those problems.
Where should focus go after Load more?
Stay on the button. The user pressed it because they want more rows, and they are likely to press it again. Moving focus into the new rows means they have to tab all the way back. Announce the delta and the total instead, so they know what arrived, and let them navigate into the new rows when they choose to.
What should the button say when everything is loaded?
Set aria-disabled=“true” and change the label to something final — “All 587 rows loaded”. Do not use the disabled attribute, which removes the control from the tab order and makes the end of the list a silent dead end for a keyboard user.
Related
Permalink to "Related"- Pagination & result-set navigation — the parent guide and the alternative model
- Announcing page changes in paginated tables — the same message discipline for numbered pages
- Progressive loading & skeleton states — each press is a load with its own obligations