Developer-driven focus management for single-page applications
“Focus on the things that are important.” For web browsers, focus means an element will be scrolled into the visible area when a user presses the Tab
key. But what happens when a single-page application disrupts the natural focus order? I am going to outline a development strategy I call developer-driven focus management.
Developer-driven focus management is determining keyboard focus after taking an action. This could include submitting a form, expanding a menu, or clicking a page link. Why would developers want to manage these interactions? To answer that question, we have to consider how client-side rendering works.
The evolution of user interfaces
Before the advent of client-side rendering, web pages required a full page request and response from the server. Browsers would receive the entire HTML, CSS, and JavaScript payload. This model worked well, but loaded elements like navigation or banners many times.
Client-side rendering listens for browser events to trigger smaller changes. If a list item is being updated instead of a full page, the interface will feel faster and more natural. This is a source of great power, but carries a weight of responsibility.
Missing their cues
Removing a component with keyboard focus also loses the browser’s point of reference. Web browsers often move focus to the next native element, but each one is different. Keyboard users must press Shift + Tab one or more times to focus on previous elements.
For screen reader users, the disruption is even worse. Unless focus is set on a parent element, screen readers will not announce (read aloud) changes to the page. Users may not know their action finished. They may have to re-orient themselves by listening to headings or landmark regions. This causes confusion and makes an application less accessible.
Take charge of your focus
The samples shown here are specific to React. Angular or Vue code requirements will be different, but the outcome should be the same. I learned this technique from Scott Vinkle’s article Creating Accessible React Apps. I added a few lines during testing to improve the user experience.
We will manage focus by adding four things to our application:
- A React class component
- A function ref to act as a temporary landmark
- A tabindex attribute with value of -1
- A componentDidMount() lifecycle method
Step 1: React class component
First we will add our class component inside the <main>
or <div role="main">
tag. This gives us access to methods we will need in future steps.
/* @flow */
import * as React from "react";
import "./PageContent.scss";
type Props = {
children?: React.Node,
};
class PageContent extends React.Component<Props> {
render() {
return (
<div className="ds-l-container">
<div className="tt-page-content">{this.props.children}</div>
</div>
);
}
}
export default PageContent;
Step 2: Function ref
Next we will add a ref
to our target <div>
. The ref gives us a functional reference to the DOM element that will receive keyboard focus.
<div className="tt-page-content" ref={(loader) => (this.loader = loader)}>
{this.props.children}
</div>
Step 3: Tabindex
Because we are setting keyboard focus on a non-focusable <div>
, we need to add a tabindex
attribute. Setting it to negative one allows focus to be set only by using JavaScript.
<div
className="tt-page-content"
ref={(loader) => (this.loader = loader)}
tabIndex="-1"
>
{this.props.children}
</div>
Step 4: Lifecyle methods
Finally, we will add the lifecycle method componentDidMount()
to <PageContent />
. We need this method because render()
manages component updates in the virtual DOM only. Trying to set focus now would have unexpected results.
The
render()
function should be pure, meaning that it does not modify component state, it returns the same result each time it’s invoked, and it does not directly interact with the browser. If you need to interact with the browser, perform your work incomponentDidMount()
or the other lifecycle methods instead.
When we call componentDidMount()
, our ref
will point to an actual DOM
node, and we can actively set focus.
class PageContent extends React.Component<Props> {
loader: ?HTMLDivElement;
componentDidMount() {
this.loader && this.loader.focus();
}
...
}
Page-length plays a part in active focus management, too. If you have pages that are short–768px or less–and pages that are longer, you should manage page scroll. Adding a window.scrollTo(0, 0)
scrolls the page to the top every time componentDidMount()
fires.
class PageContent extends React.Component<Props> {
loader: ?HTMLDivElement;
componentDidMount() {
this.loader && this.loader.focus();
window.scrollTo(0, 0);
}
...
}
The final component looks like this:
/* @flow */
import * as React from "react";
import "./PageContent.scss";
type Props = {
children?: React.Node,
};
class PageContent extends React.Component<Props> {
loader: ?HTMLDivElement;
componentDidMount() {
this.loader && this.loader.focus();
window.scrollTo(0, 0);
}
render() {
return (
<div className="ds-l-container">
<div
className="tt-page-content"
ref={(loader) => (this.loader = loader)}
tabIndex="-1"
>
{this.props.children}
</div>
</div>
);
}
}
export default PageContent;
Manage the experience
Managing focus also means managing the user experience. Browsers handle focus haloes their own way: Webkit browsers use a soft blue shadow. Firefox and IE/Edge use a dotted black line. Visual designs should provide wayfinding when focus is set in a non-standard way. Sometimes the native browser experience must be improved because it is visually distracting.
A word of warning
If you remove outlines with CSS like .no-outline { outline: 0; }
, you must provide an alternative. Otherwise, keyboard users must determine focus on their own. Consider creating a micro-interaction that fits into the visual design.
Animate to capture their attention
The interaction I created is fairly simple. A subtle animation triggers when the parent <div>
receives focus from componentDidMount
.
/* Parent div element */
.tt-page-content {
border-top: 5px solid #fff;
max-width: 625px;
outline: 0;
padding-bottom: 64px;
-webkit-transition: border-top 1s;
-o-transition: border-top 1s;
transition: border-top 1s;
}
/* Focus assigned with componentDidMount() */
.tt-page-content:focus {
border-top: 5px solid #046791;
-webkit-transition-duration: 0.5s;
-o-transition-duration: 0.5s;
transition-duration: 0.5s;
}
As long as the parent container has focus, a 5px blue bar appears underneath the global page header. When the container loses focus, the bar fades to white. The interaction serves as a discovery mechanism. Here’s how this looks when we put it all together on an application we built for HealthCare.gov:
Conduct your own keyboard test
Keyboard testing can be done anytime, and only requires access to a keyboard and web browser. Open a favorite website or application, and press the Tab
key to move forward through the page. Press Shift + Tab
to move backwards. A well-designed site should do the following:
- Highlight the focused element. Often this is a light blue halo or a dotted line around the current element.
- Enter data into a form input using letters and numbers
- Select one or more checkboxes by tabbing to the desired item, and pressing
Spacebar
- Select a radio button by tabbing to the group, and using the up and down arrow keys
- Choose an option from a select menu by pressing
Spacebar
to open the menu, and arrow keys to move up and down the list. PressSpacebar
again to make a selection.
In summary
Consider focus management as early as possible in the product development cycle. Designers and researchers will have insights you may not have considered. This will lead to a more beautiful and accessible application.
Photo composited from:
Related posts
- Accessibility Camp 2020
- Developing a focus style for a themable design system
- Accessibility: Start with the right tools
- Become an accessibility champion by using simple mockup annotations
- The importance of adding accessibility design reviews to the design process
- Setting the right benchmarks for site speed in government