Building an Accessible Navigation Menubar with React Hooks
Samson Liu
Posted on August 8, 2024
Introduction
Creating accessible web applications is not just a good practice — it's a necessity now. Recently, I had the opportunity to build a navigation menubar with a focus on a11y. As I researched, I realized how many menubars out there don't comply to the ARIA pattern. For example, did you know that a menubar should be navigated with arrow keys and manage its own focus, instead of tabbing through menu items?
Though I found some tutorials, I ended up not following them completely. I'm writing this because I think what I ended up building is worth sharing — especially if you have an affinity to small components and custom hooks.
This post won't be a step-by-step guide, as I assume you have a basic understanding of React and custom hooks. Instead, I'll focus on the key implementation details. I plan to update this article with a code sandbox example in the future when I have more time.
What are we building?
We're building a navigation menubar, similar to those you see at the top or side of many web applications. Some menu items may have submenus that open and close on mouse enter/leave or appropriate keyboard events.
For this blog, we assume the menubar is horizontally oriented, and submenus are vertically oriented.
If you ever get lost in this blog post, you can refer here for the appropriate keyboard behavior for accessibility.
HTML Markup
Semantic HTML and appropriate ARIA roles and attributes are essential for accessibility. For the menubar pattern, you can refer to the official documentation.
Here's an example of the HTML markup:
<nav aria-label="Accessible Menubar">
<menu role="menubar">
<li role="none">
<a role="menuitem" href="/">Home</a>
</li>
<li role="none">
<a role="menuitem" href="/about">About</a>
</li>
<li role="none">
<button
role="menuitem"
aria-haspopup="true"
aria-expanded="false"
>
Expand Me!
</button>
<menu role="menu">
<li role="none">
<a role="menuitem" href="/sub-item-1">Submenu Item 1</a>
</li>
<li role="none">
<a role="menuitem" href="/sub-item-2">Submenu Item 2</a>
</li>
</menu>
</li>
</menu>
</nav>
For semantic HTML, we are using the <button>
and <a>
element instead of <div>
elements. The <button>
is used as trigger buttons to open/close the pop-up submenu, so it should also have the aria-haspopup
attribute to inform screen readers. Additionally, the aria-expanded
attribute should be set appropriately to indicate whether the submenu is currently open or closed.
Components
Let's walk through the components we need. Obviously, we need an overall menu
component, as well as a menu item
component. Semantically the menubar is recursive, and could be represented by just these 2 components. But in practice, for usability, it gets a bit complicated:
Some menu items have a submenus while some don't. The menu items with submenus will need to manage the states for opening and closing submenus on hover and through keyboard events. So they need to be its own component.
Submenus need to be its own component as well. Semantically they are similar to the top level menu, as they are just containers for menu items. But submenus don't need to handle their own states or keyboard events.
I ended up building these components:
-
NavMenu
as the outermost component that encapsulates the entire menubar. -
MenuItem
as an individual menu item.-
MenuItemLink
as a menu item without a submenu. -
MenuItemWithSubmenu
as a menu item that has a submenu.
-
-
Submenu
as an expandable submenu.MenuItem
can be recursively nested within the submenu.
Focus Management
Simply speaking, "focus management" is the concept of a component keeping track of which of its child elements currently has focus. This ensures that when when the user's focus leaves and then returns to the component, the previously focused child element will automatically regain focus.
A common technique for implementing focus management is called "Roving Tab Index". In this approach, the focused element in the group has a tab index of 0
, while the other elements has a tab index of -1
. This way, when the user returns to the focus group, the element with tab index 0
will automatically be focused.
A first implementation for NavMenu
can look something like this:
export function NavMenu ({ menuItems }) {
// State to track the currently focused index
const [focusedIndex, setFocusedIndex] = useState(0);
// Functions to update the focused index
const goToStart = () => setCurrentIndex(0);
const goToEnd = () => setCurrentIndex(menuItems.length - 1);
const goToPrev = () => {
const index = currentIndex === 0 ? menuItems.length - 1 : currentIndex - 1;
setCurrentIndex(index);
};
const goToNext = () => {
const index = currentIndex === menuItems.length - 1 ? 0 : currentIndex + 1;
setCurrentIndex(index);
};
// Keyboard event handler according to aria specification
const handleKeyDown = (e) => {
e.stopPropagation();
switch (e.code) {
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
goToPrev();
break;
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
goToNext();
break;
case "End":
e.preventDefault();
goToEnd();
break;
case "Home":
e.preventDefault();
goToStart();
break;
default:
break;
}
}
return (
<nav>
<menu role="menubar" onKeyDown={handleKeyDown}>
{menuItems.map((item, index) =>
<MenuItem
key={item.label}
item={item}
index={index}
focusedIndex={focusedIndex}
setFocusedIndex={setFocusedIndex}
/>
)}
</menu>
</nav>
);
}
The e.preventDefault()
calls are used to prevent default browser behaviors, like the page scrolling down when the user presses the down arrow.
I did not implement the behavior of character keys focusing on items whose labels start with the same character. You are more than welcome to add on top of this code.
Next, Let's take a look at the MenuItemLink
component, which handles focus management for a simple menu item without a submenu. We are using useEffect
, usePrevious
and element.focus()
to focus on the element whenever focusedIndex
changes:
export function MenuItemLink ({ item, index, focusedIndex, setFocusedIndex }) {
const linkRef = useRef(null);
const prevFocusedIndex = usePrevious(focusedIndex);
const isFocused = index === focusedIndex;
useEffect(() => {
if (linkRef.current
&& prevFocusedIndex !== currentIndex
&& isFocused) {
linkRef.current.focus()
}
}, [isFocused, prevFocusedIndex, focusedIndex]);
const handleFocus = () => {
if (focusedIndex !== index) {
setFocusedIndex(index);
}
};
return (
<li role="none">
<a
ref={linkRef}
href={item.href}
role="menuitem"
tabIndex={isFocused ? 0 : -1}
onFocus={handleFocus}
>
{item.label}
</a>
</li>
);
}
Notice that the a
tag (or button
for menu items with submenus) is the element that should have the ref. This ensures that when the element is focused, default keyboard behaviors will work as expected, like navigation on Enter
.
We are adding an onFocus
event handler to account fo cases where the focus event is not triggered by a key or mouse interaction, but by assistive technology. Here's a quote from the web doc:
Don't assume that all focus changes will come via key and mouse events: assistive technologies such as screen readers can set the focus to any focusable element.
Tweak #1: Focusing Only on Keyboard Navigation
If you follow the useEffect
implementation above, you'll find that the first element will automatically have focus, even if the user hasn't used keyboard to navigate. To fix this, we can add an additional check to ensure that we only call focus()
when the user has used the keyboard to move the focus away from body.
useEffect(() => {
if (linkRef.current
&& document.activeElement !== document.body // only call focus when user uses keyboard navigation
&& prevFocusedIndex !== focusedIndex
&& isFocused) {
linkRef.current.focus();
}
}, [isFocused, focusedIndex, prevFocusedIndex]);
Logic Reuse and Custom Hook
So far, we have built functional NavMenu
and MenuItemLink
components. Let's move on to the MenuItemWithSubmenu
component.
You'll realize that this MenuItemWithSubmenu
component will use ALL the state management and keyboard handling logic from the top level NavMenu
component. Specifically, it need to keep track of the currently focused index within the submenu, and it needs to handle keyboard events to allow users to navigate within the submenu.
Duplicating this logic across multiple components would be inefficient and make the codebase harder to maintain. Fortunately, this is a perfect use case for a custom hook. Especially in this case, navigating a list of options with keyboard is a very common UX that is worth encapsulating. Outside of just the nav menubar and submenus, you can use it in a dropdown, a list of emojis reactions, or selecting from a to-do list...
By extracting the logic into a reusable custom hook, we can reduce code duplication, promote a consistent user experience, and make the codebase easier to debug and test.
I'm naming this hook useKeyboardOptions
, and it looks like this:
export function useKeyboardOptions(options) {
const [currentIndex, setCurrentIndex] = useState(0);
const goToStart = () => setCurrentIndex(0);
const goToEnd = () => setCurrentIndex(options.length - 1);
const goToPrev = () => {
const index = currentIndex === 0 ? options.length - 1 : currentIndex - 1;
setCurrentIndex(index);
};
const goToNext = () => {
const index = currentIndex === options.length - 1 ? 0 : currentIndex + 1;
setCurrentIndex(index);
};
const handleKeyDown = (e) => {
e.stopPropagation();
switch (e.code) {
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
goToPrev();
break;
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
goToNext();
break;
case "End":
e.preventDefault();
goToEnd();
break;
case "Home":
e.preventDefault();
goToStart();
break;
default:
break;
}
};
return [currentIndex, setCurrentIndex, handleKeyDown];
}
With this reusable hook, we can simplify the implementation of the NavMenu
component:
export function NavMenu ({ menuItems }) {
const [focusedIndex, setFocusedIndex, handleKeyDown] = useKeyboardOptions(menuItems);
return (
<nav>
<menu role="menubar" onKeyDown={handleKeyDown}>
{menuItems.map((item, index) =>
<MenuItem
key={item.label}
item={item}
index={index}
focusedIndex={focusedIndex}
setFocusedIndex={setFocusedIndex}
/>
)}
</menu>
</nav>
);
}
And this initial implementation of the MenuItemWithSubmenu
component can now leverage this hook:
export function NavMenuWithSubmenu({ item }) {
const [open, setOpen] = useState(false);
const [focusedIndex, setFocusedIndex, handleKeyDown] = useKeyboardOptions(menuItems);
const buttonRef = useRef(null);
const isFocused = index === focusedIndex;
const handleMouseEnter = () => setOpen(true);
const handleMouseLeave = () => setOpen(false);
return (
<li
role="none"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onKeyDown={handleKeyDown}
>
<button
ref={buttonRef}
tabIndex={isFocused ? 0 : -1}
role="menuitem"
aria-haspopup={true}
aria-expanded={open}
>
{item.label}
</button>
{"submenu" in item && open &&
<Submenu
item={item}
open={open}
focusedIndex={focusedIndex}
/>
}
</li>
);
}
Side note: as explained above, in my implementation, I have a MenuItem
as a generic component that returns either MenuItemLink
or MenuItemWithSubmenu
depending on if the prop item
has a submenu
property. I will skip the implementation details here.
Building on Top of the Custom Hook
By creating the useKeyboardOptions
hook, we've avoided duplicating the same keyboard navigation logic across multiple components. However, for the MenuItemWithSubmenu
component, we are still missing a few key behaviors:
-
Open and Close Submenus with
Enter
,Escape
Keys: We need to handle theEnter
key to open the submenu (and focus on the first element), and theEscape
key to close the submenu and return focus to the parent menu item. -
Arrow keys to Close the Submenu: In our vertical submenus,
ArrowLeft
andArrowRight
needs to be overwritten to close the submenu and move focus to the previous/next item in the menubar. If the prev/next item has a submenu, we need to open the submenu (and optionally place focus in the first item in that submenu). -
Arrow Keys to Open Submenus and Focus on First/Last Item: In our vertical submenus,
ArrowDown
should open the submenu and focus on the first item in the submenu (mandatory).ArrowUp
should open the submenu and focus on the last item in the submenu (optional). - Optional:
Space
Key to Open Submenus.
To handle these additional behaviors, we can build a more specialized hook. This hook will build upon our existing useKeyboardOptions
. Let's call it useSubmenu
as these behaviors are pretty specific to submenus.
These keyboard behaviors are tied to the open/closed state of the submenu, so we need to handle the open/closed state in this new hook. We also need to handle hover and focus events.
Since we also want to use the navigation functions from useKeyboardOptions
inside useSubmenu
, let's modify our useKeyboardOptions
to return the individual navigation functions:
export function useKeyboardOptions(options) {
// ...
// Same as above but we are returning the navigation functions
return [currentIndex, setCurrentIndex, handleKeyDown, {
goToStart,
goToEnd,
goToPrev,
goToNext,
}];
}
With that change, we can use the navigation functions in the useSubmenu
hook. In this hook, we're padding a buttonRef
, which is the ref to the trigger button for the submenu. When we close the submenu, we need to move the focus back to the trigger button.
export function useSubmenu(options, buttonRef) {
const [focusedIndex, setFocusedIndex, handleNavBarKeyDown, {
goToStart,
goToEnd,
goToPrev,
goToNext,
}] = useNavMenuBar(options)
const [open, setOpen] = useState(false);
const handleMouseEnter = () => setOpen(true);
const handleMouseLeave = () => setOpen(false) : null;
// Close the submenu on blur
const handleBlur = (e) => {
const currentTarget = e.currentTarget as HTMLElement;
if (!currentTarget.contains(e.relatedTarget as Node)) {
setOpen(false);
}
};
const openSubmenu = (e) => {
e.preventDefault();
e.stopPropagation();
setOpen(true);
};
const closeSubmenu = (e) => {
e.preventDefault();
e.stopPropagation();
setOpen(false);
};
const handleKeyDown = (e) => {
if (!open) {
switch(e.code) {
case "Enter":
case "Space":
openSubmenu(e);
break;
case "ArrowDown":
openSubmenu(e);
break;
case "ArrowUp":
openSubmenu(e);
goToEnd();
break;
}
return;
} else {
switch(e.code) {
case "ArrowLeft":
case "ArrowRight":
return;
case "ArrowUp":
e.stopPropagation();
e.preventDefault();
goToPrev();
break;
case "ArrowDown":
e.stopPropagation();
e.preventDefault();
goToNext();
break;
case "Escape":
buttonRef.current?.focus();
closeSubmenu(e);
break;
}
// Handle "Home" and "End" keys for the submenu
handleNavBarKeyDown(e);
}
return [open, focusedIndex, setFocusedIndex, {
handleMouseEnter,
handleMouseLeave,
handleFocus,
handleBlur,
handleKeyDown,
}]
}
By using this useSubmenu
hook in the NavMenuWithSubmenu
component, we can now handle all the necessary behaviors for opening, closing, and navigating the submenus:
export function NavMenuWithSubmenu({ item }) {
const buttonRef = useRef(null);
const [open, focusedIndex, setFocusedIndex, {
handleMouseEnter,
handleMouseLeave,
handleBlur,
handleKeyDown,
}] = useSubmenu(menuItems, buttonRef);
const isFocused = index === focusedIndex;
return (
<li
role="none"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
>
<button
ref={buttonRef}
tabIndex={isFocused ? 0 : -1}
role="menuitem"
aria-haspopup={true}
aria-expanded={open}
>
{item.label}
</button>
{"submenu" in item && open &&
<Submenu
item={item}
open={open}
focusedIndex={focusedIndex}
/>
}
</li>
);
}
Tweak #2: Focusing on Submenu Open
When the user opens a submenu, the focus should be placed on the first item within the submenu. We can modify our MenuItem
to accept an optional open
prop, and use it to determine when to focus the element:
export function MenuItem ({ item, index, focusedIndex, setFocusedIndex, open }) {
const elementRef = useRef(null); // could be anchor tag or button tag
const prevFocusedIndex = usePrevious(focusedIndex);
const prevOpen = usePrevious(open);
const isFocused = index === focusedIndex;
useEffect(() => {
if (elementRef.current
&& document.activeElement !== document.body // only call focus when user uses keyboard navigation
&& (prevFocusedIndex !== focusedIndex || prevOpen !== open)
&& isFocused) {
elementRef.current.focus();
}
}, [isFocused, focusedIndex, prevFocusedIndex, prevOpen, open]);
// ... Rest of the component implementation
}
Tweak #3: Identify Navigation from within Submenus
When the user presses ArrowLeft
or ArrowRight
while a submenu is open, we should close the submenu. If the new focus also has a submenu, we should open the submenu and optionally place the focus on the first item.
To implement this behavior, when a MenuItemWithSubmenu
gains focus, we need to check if the focus event is coming from a submenu. We can achieve this by tracking the depth of the MenuItem
component: if the event is coming from a deeper depth, we know it's from a submenu.
First, let's update the NavMenu
component to pass a depth prop to each MenuItem
:
export function NavMenu ({ menuItems }) {
// ... rest of the implementation
return (
{/* rest of the implementation */}
{items.map((item, index) =>
<MenuItem
key={item.label}
item={item}
depth={1}
index={index}
focusedIndex={focusedIndex}
setFocusedIndex={setFocusedIndex}
/>
)}
{/* rest of the implementation */}
);
}
In the generic MenuItem
, we use the depth
prop to set a data attribute (data-depth
) to provide the depth detail to the focus event.
export function MenuItemLink ({ item, depth, isFocused, elementRef, focusedIndex, setFocusedIndex }) {
// ... rest of the implementation
return (
{/* rest of the implementation */}
<a
ref={elementRef}
tabIndex={isFocused ? 0 : -1}
role="menuitem"
data-depth={depth}
>
{/* rest of the implementation */}
);
}
The MenuItemWithSubmenu
works similarly.
In the Submenu
component, we can increase depth
for the subsequent menu items:
export function Submenu ({ item, depth, open, focusedIndex, setFocusedIndex }) {
return (
<menu role="menu">
{item.submenu.map((item, index) => (
<MenuItem
key={item.label}
item={item}
depth={depth+1}
index={index}
focusedIndex={focusedIndex}
setFocusedIndex={setFocusedIndex}
open={open}
/>
))}
</menu>
);
}
Finally, in the useSubmenu
hook, we can add a handleFocus
handler, which will have access to the FocusEvent
. The element losing the focus is the relatedTarget
, and the element gaining focus is the target
. We can then check the data attributes by accessing relatedTarget.dataset.depth
and target.dataset.depth
. Then we set the open state if relatedTarget
has a higher depth:
// in useSubmenu
const handleFocus = (e) => {
e.stopPropagation();
const relatedTarget = e.relatedTarget;
const target = e.target;
const relatedDepth = relatedTarget?.dataset?.depth;
const targetDepth = target?.dataset?.depth
// if navigating from a deeper element, subMenu needs to stay open, per ARIA specification
if (relatedDepth && targetDepth && relatedDepth > targetDepth) {
setOpen(true);
}
}
Then you can return this handleFocus
from the custom hook and attach it to the onFocus
event handler on the button element.
Conclusion
There are a few additional considerations that could be made to further improve the accessibility and functionality of this navigation menubar:
- For mobile devices, we need to handle click/touch events to control the open/close state of submenus, and optionally disable the
mouseenter
andmouseleave
event handlers. - This component is build with a horizontal nav menubar and vertical submenus in mind. If the orientation is flipped, the keyboard handlers would need to be adjusted accordingly (e.g., swapping
ArrowLeft/ArrowRight
withArrowUp/ArrowDown
). In this case, it would also be important to set the appropriatearia-orientation
attribute on menubar and submenus.
As discussed, I want to build a code sandbox demo when I have more time. In the meantime, I hope this blog post has been a useful reference for building an accessible navigation menubar using React and custom hooks. Please let me know if you have any further questions or feedback.
Posted on August 8, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.