I recently started working on a Progressive Web App (PWA) for a client with my team. We’re using React with client-side routing via React Router, and one of the first elements that we made was the main menu. Menus are a key component of any site or app. That’s really how folks get around, so making it accessible was a super high priority for the team.
But in the process, we learned that making an accessible main menu in a PWA isn’t as obvious as it might sound. I thought I’d share some of those lessons with you and how we overcame them.
As far as requirements go, we wanted a menu that users could not only navigate using a mouse, but using a keyboard as well, the acceptance criteria being that a user should be able to tab through the top-level menu items, and the sub-menu items that would otherwise only be visible if a user with a mouse hovered over a top-level menu item. And, of course, we wanted a focus ring to follow the elements that have focus.
The first thing we had to do was update the existing CSS that was set up to reveal a sub-menu when a top-level menu item is hovered. We were previously using the visibility
property, changing between visible
and hidden
on the parent container’s hovered state. This works fine for mouse users, but for keyboard users, focus doesn’t automatically move to an element that is set to visibility: hidden
(the same applies for elements that are given display: none
). So we removed the visibility
property, and instead used a very large negative position value:
.menu-item {
position: relative;
}
.sub-menu {
position: absolute
left: -100000px; /* Kicking off the page instead of hiding visiblity */
}
.menu-item:hover .sub-menu {
left: 0;
}
This works perfectly fine for mouse users. But for keyboard users, the sub menu still wasn’t visible even though focus was within that sub menu! In order to make the sub-menu visible when an element within it has focus, we needed to make use of :focus
and :focus-within
on the parent container:
.menu-item {
position: relative;
}
.sub-menu {
position: absolute
left: -100000px;
}
.menu-item:hover .sub-menu,
.menu-item:focus .sub-menu,
.menu-item:focus-within .sub-menu {
left: 0;
}
This updated code allows the the sub-menus to appear as each of the links within that menu gets focus. As soon as focus moves to the next sub menu, the first one hides, and the second becomes visible. Perfect! We considered this task complete, so a pull request was created and it was merged into the main branch.
But then we used the menu ourselves the next day in staging to create another page and ran into a problem. Upon selecting a menu item—regardless of whether it’s a click or a tab—the menu itself wouldn’t hide. Mouse users would have to click off to the side in some white space to clear the focus, and keyboard users were completely stuck! They couldn’t hit the esc key to clear focus, nor any other key combination. Instead, keyboard users would have to press the tab key enough times to move the focus through the menu and onto another element that didn’t cause a large drop down to obscure their view.
The reason the menu would stay visible is because the selected menu item retained focus. Client-side routing in a Single Page Application (SPA) means that only a part of the page will update; there isn’t a full page reload.
There was another issue we noticed: it was difficult for a keyboard user to use our “Jump to Content” link. Web users typically expect that pressing the tab key once will highlight a “Jump to Content” link, but our menu implementation broke that. We had to come up with a pattern to effectively replicate the “focus clearing” that browsers would otherwise give us for free on a full page reload.
The first option we tried was the easiest: Add an onClick
prop to React Router’s Link
component, calling document.activeElement.blur()
when a link in the menu is selected:
const Menu = () => {
const clearFocus = () => {
document.activeElement.blur();
}
return (
<ul className="menu">
<li className="menu-item">
<Link to="/" onClick={clearFocus}>Home</Link>
</li>
<li className="menu-item">
<Link to="/products" onClick={clearFocus}>Products</Link>
<ul className="sub-menu">
<li>
<Link to="/products/tops" onClick={clearFocus}>Tops</Link>
</li>
<li>
<Link to="/products/bottoms" onClick={clearFocus}>Bottoms</Link>
</li>
<li>
<Link to="/products/accessories" onClick={clearFocus}>Accessories</Link>
</li>
</ul>
</li>
</ul>
);
}
This approach worked well for “closing” the menu after an item is clicked. However, if a keyboard user pressed the tab key after selecting one of the menu links, then the next link would become focused. As mentioned earlier, pressing the tab key after a navigation event would ideally focus on the “Jump to Content” link first.
At this point, we knew we were going to have to programmatically force focus to another element, preferably one that’s high up in the DOM. That way, when a user starts tabbing after a navigation event, they’ll arrive at or near the top of the page, similiar to a full page reload, making it much easier to access the jump link.
We initially tried to force focus on the <body>
element itself, but this didn’t work as the body isn’t something the user can interact with. There wasn’t a way for it to receive focus.
The next idea was to force focus on the logo in the header, as this itself is just a link back to the home page and can receive focus. However, in this particular case, the logo was below the “Jump To Content” link in the DOM, which means that a user would have to shift + tab to get to it. No good.
We finally decided that we had to render an interact-able element, for example, an anchor element, in the DOM, at a point that’s above than the “Jump to Content” link. This new anchor element would be styled so that it’s invisible and that users are unable to focus on it using “normal” web interactions (i.e. it’s taken out of the normal tab flow). When a user selects a menu item, focus would be programmatically forced to this new anchor element, which means that pressing tab again would focus directly on the “Jump to Content” link. It also meant that the sub-menu would immediately hide itself once a menu item is selected.
const App = () => {
const focusResetRef = React.useRef();
const handleResetFocus = () => {
focusResetRef.current.focus();
};
return (
<Fragment>
<a
ref={focusResetRef}
href="javascript:void(0)"
tabIndex="-1"
style={{ position: "fixed", top: "-10000px" }}
aria-hidden
>Focus Reset</a>
<a href="#main" className="jump-to-content-a11y-styles">Jump To Content</a>
<Menu onSelectMenuItem={handleResetFocus} />
...
</Fragment>
)
}
Some notes of this new “Focus Reset” anchor element:
href
is set tojavascript:void(0)
so that if a user manages to interact with the element, nothing actually happens. For example, if a user presses the return key immediately after selecting a menu item, that will trigger the interaction. In that instance, we don’t want the page to do anything, or the URL to change.tabIndex
is set to-1
so that a user can’t “normally” move focus to this element. It also means that the first time a user presses the tab key upon loading a page, this element won’t be focused, but the “Jump To Content” link instead.style
simply moves the element out of the viewport. Setting toposition: fixed
ensures it’s taken out of the document flow, so there isn’t any vertical space allocated to the elementaria-hidden
tells screen readers that this element isn’t important, so don’t announce it to users
But we figured we could improve this even further! Let’s imagine we have a mega menu, and the menu doesn’t hide automatically when a mouse user clicks a link. That’s going to cause frustration. A user will have to precisely move their mouse to a section of the page that doesn’t contain the menu in order to clear the :hover
state, and therefore allow the menu to close.
What we need is to “force clear” the hover state. We can do that with the help of React and a clearHover
class:
// Menu.jsx
const Menu = (props) => {
const { onSelectMenuItem } = props;
const [clearHover, setClearHover] = React.useState(false);
const closeMenu= () => {
onSelectMenuItem();
setClearHover(true);
}
React.useEffect(() => {
let timeout;
if (clearHover) {
timeout = setTimeout(() => {
setClearHover(false);
}, 0); // Adjust this timeout to suit the applications' needs
}
return () => clearTimeout(timeout);
}, [clearHover]);
return (
<ul className={`menu ${clearHover ? "clearHover" : ""}`}>
<li className="menu-item">
<Link to="/" onClick={closeMenu}>Home</Link>
</li>
<li className="menu-item">
<Link to="/products" onClick={closeMenu}>Products</Link>
<ul className="sub-menu">
{/* Sub Menu Items */}
</ul>
</li>
</ul>
);
}
This updated code hides the menu immediately when a menu item is clicked. It also hides immediately when a keyboard user selects a menu item. Pressing the tab key after selecting a navigation link moves the focus to the “Jump to Content” link.
At this point, our team had updated the menu component to a point where we were super happy. Both keyboard and mouse users get a consistent experience, and that experience follows what a browser does by default for a full page reload.
Our actual implementation is slightly different than the example here so we could use the pattern on other projects. We put it into a React Context, with the Provider set to wrap the Header component, and the Focus Reset element being automatically added just before the Provider’s children
. That way, the element is placed before the “Jump to Content” link in the DOM hierarchy. It also allows us to access the focus reset function with a simple hook, instead of having to prop drill it.
We have created a Code Sandbox that allows you to play with the three different solutions we covered here. You’ll definitely see the pain points of the earlier implementation, and then see how much better the end result feels!
We would love to hear feedback on this implementation! We think it’s going to work well, but it hasn’t been released to in the wild yet, so we don’t have definitive data or user feedback. We’re certainly not a11y experts, just doing our best with what we do know, and are very open and willing to learn more on the topic.
It’s great to see work on accessible SPA navigation, so thanks for that!
I quickly tested your CodeSandBox in Firefox/NVDA and Safari/VoiceOver (Mac). After the route link activation, the focus change did occur (I tracked it with document.addEventListener(‘focusin’, () => {console.log(document.activeElement);});), but since you are sending the focus to an aria-hidden element, both screen readers remained silent.
But you are on a good path sending focus after route transition to an element. Marcy Sutton researched a similar pattern: https://www.gatsbyjs.com/blog/2019-07-11-user-testing-accessible-client-routing/
Hey Marcus!
Thanks for the link to the article, I will definitely have a read. We had a hard time looking for such articles online when we were implementing the menu, so it’s good to know there are some out there!
Somewhat disappointed with my first interaction with the menu using an Android 11 smartphone. If I tap on “Products”, the submenu is only visible for as long as my finger is on the screen. Once I lift the finger, I am taken to the “Products” page. Basically, I can never tap on a submenu unless I do funky stuff like keeping one finger on “Products” while using a second finger to focus a link in the submenu.
Hey Sebastian, thanks for the feedback!
For our app, we don’t actually use this layout on mobile, we render the menu items differently, with touch interactions as one would expect on mobile.
This menu is intended for larger devices and devices that would typically have a keyboard input available.
Perhaps we should have added that clause at the bottom of the article, so thank you for pointing it out
To improve the range of browsers able to see the menu as intended you may need to split the :focus-within pseudo selector from the other pseudo selectors. This is because if the browser doesn’t understand ONE of the comma separated selectors, it will ignore ALL of them.
right right
Absolutely spot on Clint!
This was the first piece of feedback that Chris gave when we were working through the drafts of the article.
For brevity, I was keen to leave them grouped, but most certainly should have called out the caveat with that approach, so thank you very much for posting
Great!!!
Can you please share the site URL you worked with?
It would be great for learners like me..
Thanks in advanced
I got some good takeaways from this article, but I’m not onboard with your decisions of where to send the focus. Let me share my experience:
Sure, you can slap a tabindex on any element, but whatever is given focus must have an accessible label. To send the user to the nether and expect them to tab again to hopefully find their place in the page isn’t ideal or even acceptable.
Why mimic full-page navigation? Without a full page load, the screen reader does not act the same either. It doesn’t read the page title again like on a full page load. The user has to figure out what has changed somehow and sending them to the very top of the page is not helping that goal. Send them to the content that got updated.
To that point, what I’ve found works well in SPAs is to target the tag for both the skip nav link and any router nav (assuming only 1 router outlet for the main content in this case). You still need to put a tabindex on it. Assuming your router outlet is just inside of this, the main element is ideal for a couple reasons:
a) it’s static, wrapping your router outlet, so you don’t have to worry about async race conditions while sending focus and
b) it’s a landmark element and will be spoken as such by JAWS. If it were a div with no accessible label, a screen reader would start reading all its contents, which is not ideal either.
You can stick an aria-label or aria-described by on the main tag for more context, but I don’t recall those exact interactions now.
That was supposed to say to target the “main” tag. Looks like it ate it.
I’m also not sure why you would want to mimick the feel of a full-page reload for people relying on keyboard navigation, especially because there is a real benefit in just keeping the navigation state the way it is.
Consider tabbing through https://twitter.com/ ; you can tab from notifications to messages. Your menu stays the same. This is a much better user experience.
Now, for the users who rely on a screen reader, what you do want is some way to announce that you are on a new page for screen readers. This where an “announcer” comes in, using aria-live.
The aforementioned research by Marcy Sutton has a follow-up in this article: https://www.gatsbyjs.com/blog/2020-02-10-accessible-client-side-routing-improvements/
It is a best practice to avoid using
left: -100000px;
to kick something off the screen. For international users who may rely on auto-translating the page, it can create a scrollbar.For example, in your code change
<html lang="en">
to<html lang="ur" dir="rtl">
and note the scrollbar that appears (readers can also do this in the browser dev tools). Even if you switch to CSS logical properties, this can still be a problem.To hide something accessibly and avoid internationalization issues, take a look at Kitty Giraudel’s Hiding Content Responsibly or Scott O’Hara’s Inclusively Hidden.
A more robust solution for managing focus within client side navigation is to add
tabindex="-1"
tobody
and calldocument.body.focus()
after a route change.tabindex="-1"
won’t add an element to the tab order, yet lets it receive focus programmatically.