Published • Updated
Managing Keyboard Focus for Load-More Buttons
Many websites use a strategy known as infinite scrolling to render a continuously growing list of results in a content feed. Unfortunately, while infinite scrolling creates a seamless user experience on social media platforms, it isn’t great for accessibility. Not only does it make it impossible for both mouse and keyboard users to reach a site’s footer, but it can also create a confusing user experience for screen reader users if the proper ARIA roles and attributes are not used (e.g., aria-live
, among others).
Load-more buttons are generally preferable to infinite scrolling and create a more accessible user experience for screen reader users since they give you a choice of either loading in new content or breaking out of the feed. But if not implemented correctly, these buttons may still create a frustrating user experience for keyboard users.
In this article, we’ll look at a problem with the typical implementation for load-more buttons and explore a simple solution to make them more accessible. Code samples will be shown in React, but the basic idea can be easily extended to any framework.
Table of Contents
Problem: Keyboard Focus Sticks to Load-More Buttons
Imagine that you’re tabbing through a grid of linked cards. Your focus moves moves from one card to the next; eventually, your focus reaches the load-more button at the end of the list. You click the button by pressing the Enter or Space key on your keyboard, and new cards load into the list. Everything appears to be working correctly.
Unfortunately, if you now attempt to tab forward or backward, you’ll find that your focus resumes where it had left off—at the load-more button. The newly rendered results are in the list above this button, and the button is now all the way at the end of the page. To get to the new results, you need to tab backwards, and this can create a frustrating user experience.
Solution: Focus the First New Result
At a high level, the solution is to focus the first newly inserted result every time the list grows. Whenever new items are loaded into the list, we’ll determine the index of the first new result in the array and assign a reference to that DOM element. We’ll then focus that new element whenever the number of results changes (i.e., after ever render). Effectively, this means that after a user clicks the load-more button with their keyboard, their focus will visibly jump to the first newly inserted result.
Below is a Codepen demo showing this in action:
See the Pen Keeping focus in place with load-more buttons by Aleksandr Hovhannisyan (@AleksandrHovhannisyan) on CodePen.
Suppose we’re rendering a simple grid of results like this:
const ResultGrid = (props) => {
return (
<div>
<ol>
{props.results.map((result) => {
return (
<li key={result.id}>
<a href={result.url}>{result.title}</a>
</li>
);
})}
</ol>
{props.canLoadMore && (
<button onClick={props.onLoadMore}>Load More</button>
)}
</div>
);
};
As I mentioned before, we’ll need to maintain a reference to the first newly rendered result so we can focus that element after the list has re-rendered. So let’s create that ref ahead of time since we know we’re going to need it:
const ResultGrid = (props) => {
const firstNewResultRef = useRef(null);
// other code omitted for brevity
}
Now, we just need to somehow assign this ref to the first of the newly rendered results. And this is actually easier than it sounds! Whenever we load in more results, the index of the first new result is going to be the length of the previous array. For example, if we had 5
items before but now we have 5 + N
, the first new item’s index will always be 5
, or the length of the previous array. We’ll keep track of this index at the parent level as part of its state and pass it along as a prop to the list UI:
const App = () => {
const [state, setState] = useState({ results: [], firstNewResultIndex: -1 });
const handleLoadMore = async () => {
// logic omitted for fetching new results
setState({
...state,
results: newResults,
firstNewResultIndex: state.results.length,
});
};
return (
<ResultGrid
results={state.results}
firstNewResultIndex={state.firstNewResultIndex}
/>
);
};
Now that we have the index of the first new result, we can compare it to the index of each result in our mapping function. If an element’s index matches the target index, we’ll conditionally assign the ref to that element:
props.results.map((result, i) => {
const isFirstNewResult = i === props.firstNewResultIndex;
return (
<li key={i}>
<a
ref={isFirstNewResult ? firstNewResultRef : undefined}
href={result.url}
>
Result {i + 1}
</a>
</li>
);
})
Finally, we’ll leverage the useEffect
hook to focus the new result after every render. It’s important to specify props.firstNewResultIndex
as the only dependency of the hook; that way, we only focus the ref if this component re-rendered because new results were loaded:
// Whenever the index changes, focus the corresponding ref
useEffect(() => {
firstNewResultRef.current?.focus();
}, [props.firstNewResultIndex]);
Now, when users click the load-more button with their keyboard, their focus will jump immediately to the first of the newly inserted items.
And that’s all the logic that we need! Here’s the final code from this tutorial:
import { useRef, useState } from 'react';
const ResultGrid = (props) => {
const firstNewResultRef = useRef(null);
// Whenever the index changes, focus the corresponding ref
useEffect(() => {
firstNewResultRef.current?.focus();
}, [props.firstNewResultIndex]);
return (
<div>
<ol>
{props.results.map((result, i) => {
const isFirstNewResult = i === props.firstNewResultIndex;
return (
<li key={i}>
<a
ref={isFirstNewResult ? firstNewResultRef : undefined}
href={result.url}
>
Result {i + 1}
</a>
</li>
);
})}
</ol>
<button onClick={props.onLoadMore}>Load More</button>
</div>
);
};
const App = () => {
const [state, setState] = useState({ results: [], firstNewResultIndex: -1 });
const handleLoadMore = async () => {
// logic omitted for fetching new results
setState({
...state,
results: newResults,
firstNewResultIndex: state.results.length,
});
};
return (
<ResultGrid
results={state.results}
firstNewResultIndex={state.firstNewResultIndex}
/>
);
};
Testing Screen Reader Narration
While this article focused on keyboard users, it’s important to cover all of our bases to make sure that our solution doesn’t exclude other groups of users. In particular, we’ll want to test this approach with popular screen readers to verify that they narrate the content appropriately when the keyboard focus moves to the newly inserted items.
The examples below are from running screen readers on the CodePen demo, which originally contains a list of four items. When the load-more button is clicked, the app loads four additional items and focuses the fifth item in the list. At that point, the content is narrated as follows:
- VoiceOver: link, result 5, list 8 items.
- NVDA: list with 8 items, result 5 link.
- JAWS: list with 8 items, result 5 link.
Everything is working as expected!
Summary
Load-more buttons are great for accessibility, but you need to take care when implementing them so that you don’t create a frustrating user experience. By default, when a button is clicked via keyboard, it steals focus from the rest of the document. This is normally the expected behavior, but if the button is being used to load more results, it forces the user to tab backwards to find where they left off in the list.
In this article, we looked at how to fix that problem by focusing the first new result each time the list grows. Now, after a user clicks the load-more button with their keyboard, their keyboard focus will jump to the first newly inserted result. This allows them to continue tabbing forward normally rather than having to backtrack.
Comments
Comment on GitHubComment system powered by the GitHub Issues API. You can learn more about how I built it or post a comment over on GitHub, and it'll show up below once you reload this page.
Loading...