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.

Hands typing on keyboard

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:

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 in componentDidMount() 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:

Custom animation showing blue bar fading to white underneath the site's global header.

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. Press Spacebar 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:

Photo by LinkedIn Sales Navigator on Unsplash

Photo by Sam Goodgame on Unsplash