Accessible Load More Pattern | Nobody's Left Guessing
Results that load in silence are like a food truck that never calls out your order.
On this page
Preamble
The purpose of this article is to convey load-bearing concepts for building an accessible load more pattern.
For teaching purposes, this article abstracts the concepts of a load more pattern build into:
- HTML outputs (what is rendered in the DOM)
- generic CSS example classes
- generic JavaScript snippets
For full nuance, you can reference my GitHub code example (built using web components) and its README. There you will find both accessible and inaccessible versions that can be tested with your screen reader of choice. This code example has been tested with VoiceOver and Safari as shipped with macOS 26.3.1 (a).
VoiceOver specific quirks
During testing of the web component build, a few VoiceOver specific quirks appeared.
If a list is styled with list-style: none VoiceOver will remove the implicit list and listitem role from the <ul> / <ol> and its <li> children. You need to manually add role="list" and role="listitem" back respectively. In the code example these have been added on each element as needed, however for this write-up, I am omitting them to prevent extra noise within the code examples.
Lastly, VoiceOver / Safari have a current bug where the screen reader focus does not follow focus() calls on newly added elements. You can read about the issue (which is fixed in the Safari Technology Preview 246 build) in the links below:
Overview
Imagine standing in line at your favourite food truck.
Now imagine that this particular food truck doesn’t have any process for announcing when orders are ready. No ticket numbers, no announcements made from staff, they just put food up at the window when it’s ready.
It’s up to you to figure out if your order was completed and put up at the window.
You’d likely find this quite frustrating. In your eyes, you’ve done your part. You put the order in. But now you have to also be responsible for checking if it’s ready, leaving you in waves of confusion and frustration in the process.
This is a common experience assistive technology users find themselves in when it comes to loading search results.
Boilerplate
Let’s start with: a heading, an unordered list, a list item, and a button as boilerplate code.
<h2>Canadian National Parks</h2>
<ul>
<li>
<h3><a href="#">Banff National Park</a></h3>
<p>
Canada's first national park, established in 1885 in the Rocky Mountains. Known for ...
</p>
<span>Alberta</span>
</li>
...
</ul>
<!-- Assume the fetch() function is already wired up -->
<button id="loadMoreButton" type="button">Load more</button>As it stands now, if you load more results, you will see them visually, but no announcement will happen.
This code functions just as that chaotic food truck. It may serve up results, but it’s hard to know if they arrived.
Announcing newly loaded results (aria-live)
Initial announcement
This is where our food truck puts someone at the window to call out orders.
Under our <button>, we will want to add a <p> tag that can carry different status messages depending on the loading state of our results list. It should state either “Loading results.” if the results are currently loading, or “${amountLoaded} results loaded” when loading is successfully completed.
Note: It’s important that this status message exists in the DOM on page load, as a screen reader may not announce changes to it if it’s dynamically added.
On its own, this status message still won’t be announced to a screen reader. It needs ARIA attributes before a screen reader will pick up the status change.
Let’s add aria-live="polite" and role="status" to our <p> tag.
aria-live="polite" is implicit when role="status" is present, but it doesn’t hurt to add it and it keeps the code explicit.
Note: A value of "polite" tells a screen reader to wait to make announcements of changes to this element until other in-progress announcements are finished. In most cases, "polite" is the value you will want to use. In some specific cases, like critical errors (time sensitive, security related, or destructive behaviour), "assertive" may be the better choice, as it interrupts any currently spoken announcements.
Lastly, let’s hide this status message from view for sighted users.
To do this, we can make a .visually-hidden class. It’s important to not use something like display: none; or visibility: hidden;. Both of these approaches remove the element from the accessibility tree, meaning it will never be announced regardless of the other changes we’ve added.
Instead, we can rely on a tried and true CSS hack of old that plays with absolute positioning, overflow settings, and hard-coded height and width values:
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}Now our status message is visually hidden and ready to be announced by screen readers. Its markup should look like this:
<p
id="statusMessage"
class="visually-hidden"
role="status"
aria-live="polite"
>
${amountLoaded} results loaded.
</p>Announcement timing
Our status message has one more question to answer: when should "Loading results." be announced, if at all?
Imagine the window server calling out "Working on your order!" followed by "Order up!" a moment later. When the food is that quick, the first call is just noise, and the two blur together.
As our code stands now, the loading message does exactly that. Firing it the moment the fetch starts means a fast response produces two announcements back to back: "Loading results.", with "5 results loaded." right behind it. Since the results loaded fast, the "Loading results." message doesn’t provide much value, and becomes extra noise.
The "Loading results." message does carry value, however, in scenarios where the fetch is a bit slower.
So how can we account for both use cases? One way is to hold the loading message back for 500ms, and let any newer messages (e.g. "5 results loaded") replace it if one arrives within that window:
const statusElement = document.getElementById('statusMessage');
let announcementTimer = null;
function announce(message, delay = 0) {
// A newer message replaces one that hasn't been spoken yet
clearTimeout(announcementTimer);
announcementTimer = setTimeout(() => {
statusElement.textContent = message;
}, delay);
}
async function fetchData() {
...
// Only spoken if nothing else is announced in the next 500ms
announce('Loading results.', 500);
try {
// Fetch and render the new results
...
// Replaces "Loading results." if it hasn't been spoken yet
announce(`${amountLoaded} results loaded.`);
} catch (error) {
...
}
}As for the delay number itself, Nielsen Norman Group's Response Times: The 3 Important Limits is the usual starting point. One second is roughly the limit for a user's train of thought to stay uninterrupted, and below that, feedback isn't strictly necessary.
That guidance was written with visual feedback in mind, where showing feedback nobody needed costs almost nothing. A spinner that flashes for 300ms can be taken in at a glance, or ignored entirely, while you keep reading. Speech doesn't work that way. A screen reader works through a single queue of speech, so announcements are heard one after another, and each takes as long as it takes to say. An unnecessary "Loading results." isn't free: it spends the user's listening time and pushes the announcement that actually matters (new results) further back.
So for screen reader users, the bar for "worth saying" is higher than the bar for "worth showing."
One second is a sensible ceiling. Past that, a user pressing a button and hearing nothing starts to wonder whether it registered. Under that, you have some room: a shorter threshold means the message fires more often, a longer one means fast responses skip it entirely. In my web components code example, the fetch is artificially delayed to 1500ms so you can hear the loading state on every press: "Loading results." fires at 500ms, and the results announcement follows about a second later.
Keyboard focus (tabindex)
In the implementation thus far, when we press the load more button, the results are loaded and announced, however, our tab focus is still stuck on the button.
This becomes a large usability problem. If we load, say, 10 results when we press our button, we now have to tab backwards many times to get to the beginning of the newly added set of results.
This is disorienting. Going back to our food truck example, let’s say you put an order in and sit down at a picnic table to wait for it to be called. It’s a busy day, lots of people, lots of chatter. The food truck announces your order but you can’t quite tell where the announcement is coming from. “Is this my order?”. You start scanning around, eventually your eyes land back on the food truck and confirm “Yes they are indeed announcing my order” and you go and pick it up. You eventually get your food but it took some effort.
Luckily, there are steps we can take to make sure the focus is brought to the correct spot right away.
This implementation builds off of the great work and findings from Aleksandr Hovhannisyan’s Managing Keyboard Focus for Load-More Buttons article as well as Alessio Carnevale’s An accessible “Load more” implementation article.
In Aleksandr’s example, after the “load more” button is pressed, he brings the keyboard focus immediately to the first newly fetched result.
This approach solves the tabbing problem, but for some, having your focus switch automatically and jumping right into a new result can be a bit jarring.
The user may still expect to take an action (e.g. tab) to get to the results, at their own pace and on their own terms.
Alessio’s example outlines an approach to this. In his example, he creates a dynamically added entry, let’s call it a batch marker (since it appears before every newly loaded batch of results), just before the newest result is loaded. It receives a tabindex="-1" and the focus is brought to it manually.
Now, a user can press tab or shift tab, and be brought to the results they would expect, through their own actions.
This approach solves our food truck issue. Essentially, this makes sure our attention is directed to the food truck right when the order is announced. We can then get up, and grab our order at our own pace.
However, there are a few subtle ways this approach can be improved even further.
First, it's important to know that a focused <li> (like our batch marker) may or may not have its text announced. The listitem role doesn't build its accessible name from the text inside it, so an <li> with no aria-label has no name for a screen reader to announce. Some will fall back to reading the contents, others won't.
We already have a working status announcement from the last step. Rather than risk a duplicate announcement, we can use the same messaging here, but wrap the inner text in a <span> with aria-hidden="true" to ensure it never gets announced.
In the DOM, the marker should sit between batches like this:
<ul>
<!-- Previous batch -->
<li>
...
</li>
<li class="visually-hidden batch-marker" tabindex="-1">
<span aria-hidden="true">${amountLoaded} results loaded.</span>
</li>
<!-- New batch -->
<li>
...
</li>
...
</ul>The style rules are important here as well. We can apply the same .visually-hidden class from the previous step on this batch marker, and then override the rules for when the element is brought into focus.
Rather than using the :focus pseudo-class, we can apply :focus-visible instead. The :focus-visible pseudo-class lets the browser make the call on whether the element should get focus styling based on a variety of factors.
For our purposes, if the “Load more” button is clicked and our batch marker has this pseudo-class, it will not show to users on screen. However, if “Load more” is actioned via a keyboard press, it will show up on screen.
The CSS could look something like this:
/*
* The !important declarations are to ensure proper override of the
* .visually-hidden styles which are also applied to this element.
*/
.batch-marker:focus-visible {
position: static !important;
width: auto !important;
height: auto !important;
overflow: visible !important;
clip-path: none !important;
white-space: normal !important;
padding: 0.5rem 0 !important;
margin-bottom: 0.5rem !important;
outline: 3px solid #236DA9;
outline-offset: 2px;
}Lastly, tabindex="-1" means the keyboard focus will never land on the batch marker again via the tab key. However, in Alessio’s example the batch marker stays in the DOM indefinitely. It can still be encountered in other ways, like via a screen reader's arrow-key browse mode.
What we will want to do is remove the element from the DOM on a focusout event.
Putting the marker together, we end up with a method like this:
function createBatchMarker(message) {
const marker = document.createElement('li');
marker.className = 'visually-hidden batch-marker';
marker.tabIndex = -1;
const markerText = document.createElement('span');
markerText.setAttribute('aria-hidden', 'true');
markerText.textContent = message;
marker.appendChild(markerText);
// Remove the marker once the user navigates away from it
marker.addEventListener('focusout', () => {
// defer removal until the focus transfer has fully completed
setTimeout(() => marker.remove(), 0);
}, { once: true });
return marker;
}Then, once the marker is inserted before the new batch, we move focus to it:
marker.focus();
Disabling controls, the right way (aria-disabled)
The food truck is working at full steam. The kitchen is putting out orders fast, they are getting served at the window with an announcement, all appears well.
However, if our window server needs to take a bathroom break while the kitchen is still pumping out orders, there could be quite a backlog that builds up.
When the window server returns, they may become a bit frantic, trying to clear the backlog of orders to serve. That means they may not announce everything in the chaos while they are trying to catch up.
The same concept applies to our load more pattern. If someone rapidly presses the “Load more” button, they could trigger multiple fetches, all rushing to arrive.
Our status message will struggle to keep up, and may only announce the latest batch that came in. Meaning “5 results loaded” may be announced, when in reality maybe 15+ came in due to the “Load more” button being activated many times.
This can be confusing as there’s a clear mismatch between how many results have actually been newly loaded versus how many our status message says have been loaded in.
Your first instinct may be to reach for the disabled attribute and apply it to the button. It seems like a simple enough addition and would stop the user from activating our button many times in a row while loading is still in-progress.
However, there is a problem with the disabled attribute: disabled elements lose their place in the tab order. If the “Load more” button had focus when it was disabled, focus drops to the document body. The user loses their place, and their next Tab press starts over from the top of the page.
To solve this, we can turn to a two-part solution. The first part is to make use of the aria-disabled attribute and set it to true. This attribute tells a screen reader that the control is disabled, but allows it to remain in focus and stay in the tab order.
Note: aria-disabled is best used when a control is temporarily unavailable and will be usable again shortly. It indicates "not usable right now," which is exactly what a mid-fetch button means.
Unlike its cousin the disabled attribute, the CSS for aria-disabled elements will not change automatically. You have to add a visual disabled state yourself:
button[aria-disabled="true"] {
background: #ced7e0; /* light grey */
border: 2px solid #3f4d5a; /* dark grey */
color: #3f4d5a; /* dark grey, 5.96:1 on the fill */
cursor: not-allowed;
}Note: It's common to reach for opacity when styling a disabled state. This is permitted as WCAG grants exemptions from contrast requirements for inactive components under 1.4.3 Contrast (Minimum) (AA) and 1.4.11 Non-text Contrast (AA). However, this button is only temporarily disabled, stays in the tab order, and can hold focus while the fetch is in progress, so there's value in keeping it perceivable. Reach for opaque greys that stay readable rather than fading out the active styles.
Another difference between the two: disabled actually prevents a control from being activated. aria-disabled does not. The button still responds to clicks and key presses exactly as it did before, meaning currently it will still trigger multiple fetches if pressed.
This leads us to the second part of the solution, which is to create a loading guard that prevents our fetchData() function from doing multiple fetches while loading is still happening.
Implementations on how to do this vary, but it may look something like this:
const loadMoreButton = document.getElementById('loadMoreButton');
let loading = false;
...
async function fetchData() {
if (loading) return; // Prevent multiple simultaneous fetches
// Set to true to indicate that a fetch is in progress
loading = true;
// Indicate the "load more" button is disabled while loading
loadMoreButton.setAttribute('aria-disabled', 'true');
try {
const response = await fetch('https://example.com');
const data = await response.json();
} catch (error) {
console.error('Error:', error);
} finally {
// reset both to false on completion of the fetch
loading = false;
loadMoreButton.setAttribute('aria-disabled', 'false');
}
}With these two parts completed, we now have a working load more button that stays focusable and in the tab order even when disabled, and won’t trigger multiple fetches leading to incorrectly synced status announcements.
Tracking results while navigating (aria-label)
The window server has been workshopping order shout outs. Instead of just the item name, they've started pairing it with an order number: "Cheese Burger, order 5."
One morning, you, its frequent patron and number one customer, decide to buy the food truck out entirely.
As the kitchen starts prepping, they let you know they have capacity to fill 100 orders, batched 5 at a time.
Since you've bought the whole day's run, the order shout outs can go further and tell you how far along you are: "Poutine, order 15 of 100."
Adding set indicators to our results
The same concept can be applied to our results; we can calculate a total, and apply an individual count to each result item.
If you start researching how to do this, you will undoubtedly come across the aria-setsize and aria-posinset attributes.
Their descriptions sound like they would solve this exact use case: aria-setsize maps to total results and aria-posinset maps to each result's individual count.
However, real world testing has shown that support for these ARIA attributes varies widely across major screen readers.
The W3C ARIA-AT Community Group has put together a great initiative, aiming to test the compatibility of major screen readers against a wide variety of ARIA attributes across code examples they have authored. While not all encompassing, their ARIA feature support levels report provides a great baseline for understanding ARIA feature support.
At the time of writing (September 2026), the aria-setsize and aria-posinset attributes are well supported in JAWS/Chrome, moderately supported in VoiceOver/macOS/Safari, and have the weakest support in NVDA/Chrome.
However, there is an ARIA attribute that is highly supported that we can leverage: aria-label. By adding this information onto each result’s heading link (a tabbable element) we can keep the spirit of aria-setsize and aria-posinset alive in an attribute that is more compatible across varying screen readers.
<h3>
<a
href="#"
aria-label="Banff National Park, 1 of 32"
>
Banff National Park
</a>
</h3>We can also add the amount loaded, accrued results (the number of results on screen), and the total on our batch marker and status message:
<!-- Batch marker -->
<li
class="visually-hidden batch-marker"
tabindex="-1"
>
<span aria-hidden="true">
${amountLoaded} results loaded, ${accruedResults} of ${total} shown.
</span>
</li>
<!-- Status message -->
<p
id="statusMessage"
class="visually-hidden"
role="status"
aria-live="polite"
>
${amountLoaded} results loaded, ${accruedResults} of ${total} shown.
</p>When the results run out
Now that our messaging carries ${accruedResults} of ${total}, there is one more state to account for: what happens when all results are shown and there is nothing left to fetch.
The "Load more" button has no job at that point, so we can remove it from the DOM.
Focus first, then remove. To avoid keyboard focus being reset to the document body, the batch marker focus needs to happen before we remove the "Load more" button from the DOM.
For our announcement, instead of the usual ${accruedResults} of ${total} count, we announce that the set is complete. Our batch marker carries the same message, so both sighted keyboard users and screen reader users are told the same thing at the same moment.
const message = accruedResults >= total
? `${amountLoaded} results loaded, all ${total} results shown.`
: `${amountLoaded} results loaded, ${accruedResults} of ${total} shown.`;
const marker = createBatchMarker(message); // inserted before the new batch
marker.focus(); // focus first...
if (accruedResults >= total) loadMoreButton.remove(); // ...then remove
announce(message);Error handling (aria-live)
After running smoothly for weeks, the food truck’s grill and fryer break mid service. The owner decides to put up a sign indicating the kitchen is having problems and the food truck will be shut down temporarily, and they politely tell people to come back in a little bit.
For handling errors within our load more pattern, we can reuse similar techniques to the ones we used when announcing new results. We can set the status message text appropriately for our screen reader users, while having a separate message section appear for sighted users.
Let’s add some error text to our status message. In JavaScript, you can set error messaging on this status element from within your fetch's catch block. It may look something like this:
const ERROR_MESSAGE = 'Could not fetch new results. Please try again.';
...
async function fetchData() {
...
try {
...
} catch (error) {
console.error('Error:', error);
announce(ERROR_MESSAGE);
} finally {
...
}
}That satisfies the messaging for screen reader users; however our sighted users still need some sort of indicator.
First, let’s create some example styling for this error message:
.error-message {
color: #c62828;
font-weight: bold;
}Next, let’s create two methods that let us dynamically create and remove an error message for our sighted users, similar to what we have done for our batch marker. In JavaScript, you may have 2 functions that look like this:
// Create, add styling, and inject to the DOM
function showErrorMessage(message) {
clearErrorMessage();
const errorElement = document.createElement('p');
errorElement.className = 'error-message';
errorElement.setAttribute('aria-hidden', 'true');
errorElement.textContent = message;
loadMoreButton.before(errorElement);
}
// Remove from the DOM
function clearErrorMessage() {
const errorElement = document.querySelector('.error-message');
if (!errorElement) return;
errorElement.remove();
}Now we just have to call these two methods inside of fetchData():
const ERROR_MESSAGE = 'Could not fetch new results. Please try again.';
...
async function fetchData() {
...
clearErrorMessage(); // Reset before every attempt
try {
...
} catch (error) {
console.error('Error:', error);
announce(ERROR_MESSAGE); // For screen reader users
showErrorMessage(ERROR_MESSAGE); // For sighted users
} finally {
...
}
}Putting it all together
Our food truck and load more pattern have come a long way. We can now announce newly loaded results, land keyboard focus where a user expects it, disable controls correctly during loading, tell a user where each result sits within the total set, and communicate errors.
Let's look at all the pieces assembled. The fetch and rendering are left abstract here, so this is illustrative rather than copy-and-run. The full working version is in the GitHub web components code example.
First up, our JavaScript methods:
const statusElement = document.getElementById('statusMessage');
const loadMoreButton = document.getElementById('loadMoreButton');
const ERROR_MESSAGE = 'Could not fetch new results. Please try again.';
let loading = false;
let announcementTimer = null;
function announce(message, delay = 0) {
// A newer message replaces one that hasn't been spoken yet
clearTimeout(announcementTimer);
announcementTimer = setTimeout(() => {
statusElement.textContent = message;
}, delay);
}
// Build the batch marker, our keyboard focus landing zone
function createBatchMarker(message) {
const marker = document.createElement('li');
marker.className = 'visually-hidden batch-marker';
marker.tabIndex = -1;
const markerText = document.createElement('span');
markerText.setAttribute('aria-hidden', 'true');
markerText.textContent = message;
marker.appendChild(markerText);
marker.addEventListener('focusout', () => {
// defer removal until the focus transfer has fully completed
setTimeout(() => marker.remove(), 0);
}, { once: true });
return marker;
}
// Create, add styling, and inject to the DOM
function showErrorMessage(message) {
clearErrorMessage();
const errorElement = document.createElement('p');
errorElement.className = 'error-message';
errorElement.setAttribute('aria-hidden', 'true');
errorElement.textContent = message;
loadMoreButton.before(errorElement);
}
// Remove from the DOM
function clearErrorMessage() {
const errorElement = document.querySelector('.error-message');
if (!errorElement) return;
errorElement.remove();
}
async function fetchData() {
if (loading) return;
loading = true;
loadMoreButton.setAttribute('aria-disabled', 'true');
clearErrorMessage(); // Reset before every attempt
// Only spoken if nothing else is announced in the next 500ms
announce('Loading results.', 500);
try {
// Successfully fetched data, results appended to the list
// amountLoaded: how many results this batch returned
// accruedResults: how many are now on screen
// total: the full result count, usually from the response body
...
const message = accruedResults >= total
? `${amountLoaded} results loaded, all ${total} results shown.`
: `${amountLoaded} results loaded, ${accruedResults} of ${total} shown.`;
// Focus first, then remove
const marker = createBatchMarker(message);
// marker is inserted before the new batch
marker.focus();
if (accruedResults >= total) loadMoreButton.remove();
// Replaces "Loading results." if it hasn't been spoken yet
announce(message);
} catch (error) {
console.error('Error:', error);
announce(ERROR_MESSAGE); // For screen reader users
showErrorMessage(ERROR_MESSAGE); // For sighted users
} finally {
loading = false;
loadMoreButton.setAttribute('aria-disabled', 'false');
}
}Next, our CSS:
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/*
* The !important declarations are to ensure proper override of the
* .visually-hidden styles which are also applied to this element.
*/
.batch-marker:focus-visible {
position: static !important;
width: auto !important;
height: auto !important;
overflow: visible !important;
clip-path: none !important;
white-space: normal !important;
padding: 0.5rem 0 !important;
margin-bottom: 0.5rem !important;
outline: 3px solid #236DA9;
outline-offset: 2px;
}
button[aria-disabled="true"] {
background: #ced7e0; /* light grey */
border: 2px solid #3f4d5a; /* dark grey */
color: #3f4d5a; /* dark grey, 5.96:1 on the fill */
cursor: not-allowed;
}
.error-message {
color: #c62828;
font-weight: bold;
}And the resulting DOM should look like this:
<h2>Canadian National Parks</h2>
<ul>
<!-- First batch, loaded on page load -->
<li>
<h3>
<a href="#" aria-label="Banff National Park, 1 of 32">
Banff National Park
</a>
</h3>
<p>
Canada's first national park, established in 1885 in the Rocky Mountains. Known for ...
</p>
<span>Alberta</span>
</li>
<!-- ... Results 2 through 5 ... -->
<!-- Batch marker: inserted before each new batch, removed on focusout -->
<li class="visually-hidden batch-marker" tabindex="-1">
<!-- Message changes depending on fetch state / last batch -->
<span aria-hidden="true">5 results loaded, 10 of 32 shown.</span>
</li>
<!-- Second batch -->
<li>
<h3>
<a href="#" aria-label="Jasper National Park, 6 of 32">
Jasper National Park
</a>
</h3>
<p>
The largest national park in the Canadian Rockies, home to the Icefields Parkway ...
</p>
<span>Alberta</span>
</li>
<!-- ... Results 7 through 10 ... -->
</ul>
<!-- Only in the DOM while an error is being shown -->
<p class="error-message" aria-hidden="true">
Could not fetch new results. Please try again.
</p>
<!-- aria-disabled="true" is added while a fetch is in progress -->
<!-- Removed entirely if there are no more results to load -->
<button id="loadMoreButton" type="button">Load more</button>
<p
id="statusMessage"
class="visually-hidden"
role="status"
aria-live="polite"
>
<!-- Message changes depending on fetch state / last batch -->
5 results loaded, 10 of 32 shown.
</p>POUR retrospective
Our food truck's build is complete. What's left is a final inspection, checking everything we just did against accessibility's own standard.
WCAG criteria are assessed against four accessibility principles. These principles are known as POUR (Perceivable, Operable, Understandable, and Robust).
Let’s see how our build fulfills them.
Perceivable
- Success and failure are always announced via the
aria-live="polite"status message. Loading is announced only when the fetch takes longer than 500ms, since a fast response makes that message noise rather than information. - When the “Load more” button is activated via keyboard, our batch marker becomes visible with the same update text, thanks to
:focus-visible. - Screen reader users are told which item in the results set they are on, via the
aria-labelon each result’s heading link. - Both the batch marker and the status message indicate how many new results were loaded in, and indicate where in the total set these new results fit.
- Every state change is communicated to sighted keyboard users and screen reader users alike, each through a channel that suits how they are navigating.
Operable
- The batch marker with
tabindex="-1"gives newly loaded results a proper landing zone, reachable on the next tab press, and is removed once focus moves away. On the final batch it receives focus before the "Load more" button is removed, so focus is never sitting on an element that leaves the DOM. aria-disabledoverdisabledkeeps the "Load more" button in the tab order while a fetch is in progress, so a keyboard user can still reach it and hear that it is unavailable.
Understandable
- Every status message has plain language and distinct text: loading, success with a count, failure with an explicit next step ("please try again").
- The loading guard keeps the status message accurate. Without it, rapid presses could load 15 results while the announcement reports only the most recent batch.
aria-hiddenon the batch marker's inner text and on the visible error message prevents the same information being spoken twice in two different ways, which would be confusing rather than clarifying.
Robust
- Using
aria-labeloveraria-setsize/aria-posinsetdue to the latter pairing having inconsistent support across screen readers. role="status"paired witharia-live="polite", even though one implies the other, for maximum compatibility across screen readers.
Wrap up
Everything above is a checklist. What it adds up to is more meaningful than a checklist can show.
Our food truck started this piece with no way of letting anyone know an order was ready, every customer left to do the guesswork themselves. It's ended somewhere very different: a truck that tells you when your order's up, points your attention to where it landed, briefly pauses without losing track of you, tells you exactly where you stand, and owns up honestly when the grill breaks.
None of that was ever about doing something extra for screen reader users. It was about making sure everyone gets the same information, at the same moment, in whatever form they can actually receive it. The next time you reach for a "load more" pattern for batching results, ask what a screen reader user would actually hear the moment new content loads in. If the honest answer is "nothing," that's the food truck this article started with. You already know what to build instead.
Resources
Want to keep learning? Here are some resources to deepen your understanding of this load-more pattern:
- (GitHub) Accessible Load More Pattern Code Example
- (YouTube) Coming Soon!
- (Aleksandr Hovhannisyan) Managing Keyboard Focus for Load-More Buttons
- (Alessio Carnevale) An accessible “Load more” implementation
- (Nielsen Norman Group) Response Times: The 3 Important Limits
- (W3C ARIA-AT) ARIA feature support levels report
- (W3C ARIA-AT) Community Group
- (W3C WAI) Understanding the Four Principles of Accessibility
Attributes and methods referenced
MDN resource documentation for each attribute and method referenced in this article:
- ARIA: aria-disabled attribute
- ARIA: aria-hidden attribute
- ARIA: aria-label attribute
- ARIA: aria-live attribute
- ARIA: aria-posinset attribute
- ARIA: aria-setsize attribute
- ARIA: status role
- disabled HTML attribute
- Element: focusout event
- :focus-visible CSS pseudo-class
- tabindex HTML global attribute
- Window: clearTimeout() method
- Window: setTimeout() method
WCAG criteria
The WCAG 2.2 criteria this pattern satisfies, across this article and the code example: