This article is intended for technical audiences such as front-end developers. It relies heavily on code examples and framework-specific terminology.
In the past, I’ve written about challenges that JavaScript frameworks bring to accessibility. Today I’d like to focus more closely on one way that single page app frameworks create barriers for people using assistive technology—client-side routing.
For those that may not be familiar, client-side routing is where navigating between pages happens dynamically instead of your browser making separate requests to the web server. For example, your page-specific markup might be wrapped in a particular <div>
element. When moving between pages, the JavaScript framework would swap out that <div>
with another that contains the markup for a different page. All of this happens inside your browser because it has already downloaded the markup for each page in your app. Traditional page loading on the other hand would have your browser reach out to the web server to grab new markup each time you load a new page.
Client-side routing can be great for performance, but it also comes with some potential side effects for accessibility.
WCAG Conformance
The Web Content Accessibility Guidelines are the current set of standards for measuring digital accessibility. It has a few success criteria that single page applications will almost always impact:
Page titles
As the name suggests, a single page web app (SPA for short) is one HTML document as far as your browser is concerned, which means the page title stays the same across all pages. Unless you account for that, your website wouldn’t meet Success Criterion 2.4.2 Page Titled. Even though these apps are technically coded as a single document, they are perceived by your users as multiple pages. The Understanding SC 2.4.2 documentation even calls out that distinct views in a single page app should be treated as individual pages for the purpose of this criterion:
In cases such as Single Page Applications (SPAs), where various distinct pages/views are all nominally served from the same URI and the content of the page is changed dynamically, the title of the page should also be changed dynamically to reflect the content or topic of the current view.
Here are a few examples of how updating page titles will benefit your users:
- People who use screen readers rely on descriptive page titles to orient themselves in their mental model of where they are in a site. For example, when switching between windows or tabs, screen readers announce each web page’s title. Having the same title for each page makes it difficult to differentiate if they have multiple windows or tabs open for the same site.
- Descriptive page titles help people determine if the web page is relevant to what they’re looking for without having to read through its content.
- Page titles are also stored in your browser’s search history. While this isn’t strictly an accessibility benefit, having unique page titles helps everyone navigate their history more efficiently.
Reading order and focus order
Since the framework dynamically swaps out elements in the DOM, it could disrupt the order that keyboard users or assistive technologies read through the content. This could create problems with Success Criterion 1.3.2 Meaningful Sequence and Success Criterion 2.4.3 Focus Order.
When focus isn’t managed, it can be disorienting or confusing when people navigate between pages using assistive technology.
- Screen-magnification software might shift the user’s view to an empty space in the top left corner. Andrew Nevins demonstrated this in his article How To Avoid Breaking Web Pages For Keyboard Users.
- A screen reader’s virtual focus might reset to the
<body>
element, which means that navigating with the virtual cursor would unexpectedly restart from the very beginning of the document. This makes it unclear where the new content loaded in the page. - Keyboard focus might not continue from a logical position. Pressing Tab to move forward through the new content could resume the user’s position at the end of the new content instead of the beginning.
Past research
The effects of client-side routing aren’t new, and the accessibility community has known about these challenges for years. Fortunately, this means that people have already been researching solutions.
In 2019, Gatsby and Fable Tech Labs conducted user testing on client-side routing. A few key takeaways from their research that I’d like to emphasize are:
- “Focusing on a heading was found to be the best experience” for people using screen-reading software.
- People using screen magnification found “visible focus outlines to be helpful” when focus moved to the new content.
- “Moving focus and showing a visible focus outline was helpful” for people navigating by voice.
- “Focusing on a heading or smaller element was easier” compared to wrapper elements whose focus outlines weren’t fully visible within the viewport.
This feedback makes it clear to me that focus management is vital. I see many websites try to take this into account. They’ll move focus to an element like the <h1>
at the beginning of the page-specific content.
But one gap that I often find is that focus moves on the site’s initial page load—not just when navigating within the website. Moving focus when a single page app first loads is problematic because that makes it harder for some users to discover the skipped content. For example, a blind screen-reader user might not be aware of a site’s navigation if they always have to move backward to find it. (Or it may just be frustrating to move through it in reverse.)
A clearly visible focus outline isn’t specific to these frameworks, so I won’t go into much detail on that. If you’d like to read more on focus indicators, I’d recommend the short article Why focus indicators are key to web accessibility as a primer.
Here’s our list of goals:
- Update the page title
- Move focus when navigating between pages
- Don’t move focus on the initial page load
Let’s look at how to achieve these things using two common frameworks. If you’d like to see these code examples without my interspersed explanation, feel free to jump to the summary at the end of this article.
I don’t consider myself to be an expert in React or Angular. And there may be ways to optimize these examples, or there may be other approaches that work just as well. These are simply my recommendations based on what had all the desired outcomes during my independent testing.
React solution
This technique relies on React’s useEffect() hook to update the page title and manage keyboard focus. It stores a state variable using the History API that tracks whether the route should move the focus point.
Router type
Since this approach uses the DOM History API to track navigation state, it uses createBrowserRouter
from React Router. Based on React’s documentation, that’s the recommended router type for web projects.
Imports
First, you’ll need to import a few things from React and React Router.
import {useEffect, useRef} from "react";
import {useLocation} from "react-router-dom";
useEffect
is a React hook that fires whenever the related component renders. You might already be using this hook for things like API requests to fetch the latest data from a database.useRef
is React’s way of referencing elements in the JSX. Since DOM elements periodically rerender in React, it’s not always reliable to reference them usingdocument.getElementById()
like you would in vanilla JavaScript.useLocation
is how you’ll interact with the History API to track state variables between renders. SinceuseEffect
fires every time the component renders (including the initial time) you’ll have to conditionally set focus inside the hook. Setting a state variable when routing occurs will help with this.
Setup
In the beginning of your component, create a constant for the element that you want to use as the focus target. It’s best practice to use a semantic heading at the beginning of the new content as the focus target (you’ll see this assigned a little later).
const focusTarget = useRef(null);
Then create a variable from useLocation()
that you can use to access states from the History API.
let {state} = useLocation();
Add your constant from earlier to the target element in your component’s JSX. If your focus target isn’t a natively focusable element, make sure you also set the tabIndex
prop on it so it can receive programmatic focus without being included in the tab order. (Natively focusable elements are those such as links, buttons, and form fields.)
Since this is in JSX and not HTML, use the tabIndex
prop (with a capital “I”) instead of the tabindex
HTML attribute.
<h1 tabIndex="-1" ref={focusTarget}>My Page Heading</h1>
You can use the useRef
hook to set a focus target for any dynamic content in React, not just during page routing. For example, you can use it to focus a dialog when it opens or to move focus to a new set of search results when the user activates a Load More button.
Logic
Now that you have everything set up, you can create your useEffect
hook to handle the page title and focus logic.
You should only move focus when the user lands on this page through client-side routing. This is where the state variable comes in.
- First, check to make sure that the state variable is set to
true
before moving the user’s focus. - Next, use the
.focus()
function on your focus target. - Lastly, reset the state variable to
false
for the next time the component renders.
useEffect(() =>
{ document.title = "Your page-specific title"; if(state?.isNavigating) { focusTarget.current.focus(); window.history.replaceState({...state, isNavigating: false}, ''); } }, []);
If you don’t like the idea of accessing document.title
directly in your React code, one alternative that you can use is react-helmet. That lets you manage changes to the document <head>
with a more React-friendly syntax.
<Helmet>
<title>Your page-specific title</title>
</Helmet>
Console warning
If you include an empty dependency array for your useEffect
hook, you’ll see this console warning when compiling your application:
React Hook
useEffect
has a missing dependency: ‘state’. Either include it or remove the dependency array
Ignore this warning. If you include the state variable in the dependency array, it will trigger useEffect
whenever it detects an update for that variable. And since you’re updating the variable inside the hook, that would create an infinite loop and crash your app.
From what I understand, unless you’re doing more in the hook than handling the routing’s accessibility, passing an empty array will offer the best performance since that will only run the hook for the initial render.
For more information on the dependency array, see Examples of passing reactive dependencies.
Now, when you use a Router Link to trigger client-side navigation, you can set the isNavigating
state variable to true
and that will trigger the focus logic from useEffect
. But if the user lands on the page for the first time, then the state variable won’t resolve. So focus won’t move unexpectedly!
<Link to='YourRoute' state={{isNavigating: true}}>Page Name</Link>
Angular solution
Fortunately, Angular handles page titles as part of its Router setup. So the bulk of your work is focus management. You’ll do this using an attribute directive.
Page title
When you’re defining your Routes array, assign the title
property to each object. This sets the page title when the corresponding route resolves.
export const routes: Routes = [
{
path: '',
title: 'Home',
component: HomeComponent,
}
...
];
Directive
An attribute directive handles all the focus management. First, import a few things that you’ll need from @angular/core
.
import { Directive, AfterViewInit, ElementRef } from '@angular/core';
Directive
lets you use the@Directive
decorator to set the CSS selector that will trigger this class.AfterViewInit
gives you access to thengAfterViewInit
hook.ElementRef
lets you reference the focus target.
Class definition
Export a class with the @Directive
decorator. This example uses the attribute appRouteTarget
as its selector, but you could use any valid name for the custom attribute. Then place this attribute on the element in your template that you want to move focus to.
The logic for when focus moves should happen after the view finishes loading in the DOM. The class implements AfterViewInit
, so you can use the ngAfterViewInit()
lifecycle hook.
@Directive({
selector: '[appRouteTarget]',
standalone: true
})
export class RouteTargetDirective implements AfterViewInit {
...
}
Now that you have the class’s scaffolding in place, create an empty constructor function that passes in an ElementRef
parameter.
Define a ngAfterViewInit()
method that will host all the focus logic.
constructor(private ref: ElementRef) {}
ngAfterViewInit() {
...
}
Inside ngAfterViewInit()
, save the current value of the relevant state variable that’s coming from the History API. You’ll use this to determine whether the route occurred from an in-app link or if the user landed on the URL directly.
- First, check to make sure that the variable is set to
true
before moving the user’s focus. - Next, use the
.focus()
function on your focus target. - Lastly, reset the state variable to
false
for the next time the component is rendered.
var shouldMoveFocus = window.history.state.isNavigating;
if(shouldMoveFocus) {
this.ref.nativeElement.focus();
window.history.replaceState({isNavigating: false}, '');
}
Linking within your app
In your page component’s .ts file, import the new directive and add it to the component’s imports array.
import { RouteTargetDirective } from '../route-target.directive';
...
imports: [ RouteTargetDirective ];
In the corresponding template file, add the appRouteTarget
attribute to the element that you want to receive focus. If the element isn’t natively focusable (like a link, a button, or a form field), make sure that you also give it tabIndex="-1"
.
<h1 tabIndex="-1" appRouteTarget>My Page Heading</h1>
Now, when you use a routerLink
to trigger client-side navigation, you can set the isNavigating
state variable to true
and the directive will trigger. But if the user lands on the page for the first time, then the state variable won’t resolve. So focus won’t move unexpectedly!
<a routerLink="/" [state]="{isNavigating: true}">Home</a>
Summary – all the code examples with none of the explanation
In case you want to see the code put together without all the fluff explaining how it works, here you go!
React
In your page components
import {useEffect, useRef} from "react";
import {useLocation} from "react-router-dom";
import {Helmet} from "react-helmet";
export default function MyPageComponent (props) {
const focusTarget = useRef(null);
let {state} = useLocation();
useEffect(() =>
{ if(state?.isNavigating) { focusTarget.current.focus(); window.history.replaceState({...state, isNavigating: false}, ''); } }, []); return ( <Helmet> <title>My Page Title | My Site Title</title> </Helmet> … <h1 tabIndex="-1" ref={focusTarget}>Your page heading</h1> … } }
Router links to your page component
import {Link} from 'react-router-dom';
…
export default function SomeOtherPageComponent (props) {
return (
…
<Link to='/MyPageComponent' state={{isNavigating: true}}>Page Name</Link>
…
}
}
Angular
Setting page titles in app.routes
export const routes: Routes = [
{
path: 'mypage',
title: 'My Page Title | My Site Title',
component: MyPageComponent,
},
…
];
Creating a directive for focus management
import { Directive, AfterViewInit, ElementRef} from '@angular/core';
@Directive({
selector: '[appRouteTarget]',
standalone: true
})
export class RouteTargetDirective implements AfterViewInit {
constructor(private ref: ElementRef) {}
ngAfterViewInit() {
var shouldMoveFocus = window.history.state.isNavigating;
if(shouldMoveFocus) {
this.ref.nativeElement.focus();
window.history.replaceState({isNavigating: false}, '');
}
}
}
In your page components’ .ts files
import { RouteTargetDirective } from '../route-target.directive';
@Component({
selector: 'app-my-page-component',
imports: [ RouteTargetDirective ],
templateUrl: './my-page-component.component.html',
styleUrl: './my-page-component.component.scss'
})
export class MyPageComponent {
…
}
In your page components’ template files
<h1 tabIndex="-1" appRouteTarget>Your page heading</h1>
Router links to a page component
<a routerLink="/mypage" [state]="{isNavigating: true}">Page Name</a>
Some final thoughts
We must pay attention to the needs of assistive technology users as technology continues to evolve. Single page app frameworks have been around for over a decade, and yet we’re still exploring solutions for how to use them inclusively. There’s no reason why SPAs can’t be accessible—it just takes a little effort. The examples in this article would be a relatively small change in your code and would have no no obvious change to users without disabilities. But they could make a big difference to assistive technology users. There’s no reason not to give them a try!
Image credit: trekandshoot.