Stop losing focus!
Improve Accessibility in Your React App By Managing Focus in Mutable Content
Leverage the component “lifecycle” with useEffect to set the active element in the DOM
When building user interfaces we follow a design, making assumptions about how the user will interact with the frontend. We display data in a certain way, provide buttons to be clicked, and update the UI according to state changes.
For accessibility, especially with interactive UI, you should not only consider what the user sees, but what a screen reader “sees” too. This is important since the screen reader will communicate to the user how to use your application. If the screen reader can’t correctly interpret your frontend, then most likely the person using it won’t either.
With mutable content, building accessible UI becomes more complex and is not just a matter of using semantic HTML. By mutable content, I mean content that can be changed in some way, usually with CRUD operations (create, read, update, delete). While dynamic changes to the UI can be announced with ARIA live regions, I have found that focus handling is often missed.
The Problem
Imagine you are navigating through a mutable list, this could be a social feed, comments in a discussion thread, or even the items in your online shopping basket. What happens when you delete an item? The element you were focused on is suddenly no longer in the DOM, so document.activeElement
will shift back to the <body>
element. This means the user has to navigate through the entire page again to get back to where they were. You can imagine how annoying this is for users who use assistive technology or keyboard navigation.
You can check the current active element in the DOM using the chrome devtools by going to Console > Create Live Expression (the eye icon) and typing document.activeElement
.
In React web applications, programatically changing the active element is surprisingly easy to handle. I admit when looking for a solution to this problem I went down several over complicated routes until I realised the obvious — with React refs I had access to the Node API.
The Solution
An example with a TypeScript Todo List App
We can illustrate a solution to this problem with a simple todo list. In the gist below, we have a React app that stores an array of todos in state, and iterates through them to render each one with a ListItem
component. There is a <form>
to add todos, and a function to delete todos which is passed to the ListItem
.
For accessibility, I’ve added some state to manage the display of the application status which will notify the user when the list has been modified. This can be hidden visually with css if it is not part of the design, but can still be accessible to screen readers. The role="status"
attribute implicitly applies the aria-live
value of polite
, meaning it will wait until the user has stopped what they are doing before making the announcement, as to not interrupt them.
Handling Focus
When an item is deleted from the list, we need to prevent the document.activeElement
from shifting back to the <body>
so the user doesn’t have to start over. Before we can do this, we need to define the expected behaviour and any edge cases. In the context of this application, I think it makes the most sense that when a todo is deleted, it should:
- Shift focus to the next todo.
- If there is no next todo (e.g. it is the last in the list), it should shift focus to the previous todo.
- If there is no previous todo, it should shift focus to the parent element.
In the case 3, it may be tempting to programatically focus the user back to the create todo <input>
since there are no more todos in the list. Although you could do this, I don’t believe you should. I am a firm believer in letting the user decide where to navigate, with a few exceptions (e.g. changing focus to when a modal dialog is triggered). Suddenly changing focus to somewhere completely different on the DOM can be very confusing. Even as a sighted user I despise sites that hijack my scroll or force me to navigate to places without my consent. Remember, the goal here is simply to help the user maintain their current place in list when entries are deleted, not to force them down a user flow.
In our ListItem
component, we can use a ref
to access the DOM Node created in the component render. Since the ref
is a DOM Node, it means we have access to all the Node API properties and methods, including nextElementSibling
, previousElementSibling
, and parentElementNode
.
Note: unlike nextSibling
, previousSibling
, and parentNode
, nextElementSibling
, previousElementSibling
, and parentElementNode
will return an HTML Element or null
whereas the former could return other node types such as a Document Node or Text Node.
To set the document.activeElement
when a todo is deleted, we can use the useEffect
hook’s cleanup function, as it will execute when the component is unmounted. We also need to add a tabindex
to the elements we are going to call focus()
on to ensure that we can programatically focus on them to set the document.activeElement
. Since <ol>
and <li>
are not natively focusable elements like <button>
or <a>
I’m applying a tabindex
of -1
. This means its not focusable through sequential keyboard navigation, but it can still be programatically focused to be set as the document.activeElement
so that the user does not loose their place while going through the list.
Common mistakes
When writing your cleanup function, it may be tempting to store the nextElementSibling
and previousElementSibling
in a variable, especially if you are using them in the code more than once (which I am doing in the example). This can cause the focus management to not work properly as it will give you the sibling element at the time your cleanup function is defined (on component mount), not at the time that the cleanup function is executed. This means you may be trying to focus on an element that has been deleted, or is no longer the next or previous sibling of the selected element.
However, the ref
which refers to the element in the component instance should not change, regardless of changes to the list. Therefore, if you access the nextElementSibling
or previousElementSibling
property directly on the ref
at the time that your function executes, it should always return the correct element.
Conclusion
When building interactive user interfaces, it is important to always ask yourself “what would the screen reader do?” . This helps to ensure you develop the application so it can be used by all users, including those who rely on assistive technology, especially when it comes to focus management. In React, we can leverage the component lifecycle, refs, and the Node API to help us manage which node should be the document.activeElement
.
I strongly recommend testing your application with a screen reader like VoiceOver, or even just navigating with a keyboard. As a sighted user fully capable of using a mouse, I often find keyboard navigation quicker and easier. An application that doesn’t cater for that just becomes frustrating.
Accessibility should not be an afterthought, or considered only needed by users with disability. Improving accessibility improves the user experience for everyone.
The full code repository for the examples shown in this article can be found at: https://github.com/laij84/mutable-list-focus