Building an Accessible Navigation Menubar with React Hooks

godsamit

Samson Liu

Posted on August 8, 2024

Building an Accessible Navigation Menubar with React Hooks

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>
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 the Enter key to open the submenu (and focus on the first element), and the Escape key to close the submenu and return focus to the parent menu item.
  • Arrow keys to Close the Submenu: In our vertical submenus, ArrowLeft and ArrowRight 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,
  }];
}
Enter fullscreen mode Exit fullscreen mode

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,
  }]
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 */}
  );
}
Enter fullscreen mode Exit fullscreen mode

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 */}
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
Enter fullscreen mode Exit fullscreen mode

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 and mouseleave 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 with ArrowUp/ArrowDown). In this case, it would also be important to set the appropriate aria-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.

💖 💪 🙅 🚩
godsamit
Samson Liu

Posted on August 8, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related