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.

Load more versus scroll-triggered loading Comparison of an explicit load-more button against scroll-triggered infinite loading across keyboard operability, footer reachability and announcement. Load more versus scroll-triggered loading✓ Load more buttonA real control in the tab orderThe footer stays reachableOne announcement per batch, user-initiatedFocus stays on the button for the next press✗ Scroll-triggered loadingNo control to operateThe footer recedes on every approachEither silent or constantly announcingPosition in the set is unknowable
The difference is not preference — one of them has no keyboard path at all.

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.

What happens when a batch appends Three-step sequence for appending a batch: append the rows, keep focus on the button, and announce how many were added and the new total. What happens when a batch appendsAppend the rowsinsert after the last existing rowNever re-render the whole listLeave focus alonethe button keeps focus for the next pressMoving focus here punishes the common caseAnnounce the delta"24 more rows loaded, 74 of 587 shown"The delta and the total, in one message
Appending is the one case where focus should NOT move.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
The load-more keyboard contract Keyboard contract for an appending list: Enter and Space load the next batch, and the control announces its own exhausted state. The load-more keyboard contractEnter / SpaceLoad the next batchAnnounce the delta and the running totalTabReach the button after the last rowIt must come after the rows in DOM orderEnter on the last batchLoad the remainderAnnounce "All 587 rows loaded" and set aria-disabled
When there is nothing left to load, say so — do not silently disable the button.
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.

Permalink to "Related"

Back to Pagination & Result-Set Navigation