Skip to Content
Course content

131: Keyboard Navigation for Custom Widgets

Click on the "Edit" button in the top corner of the screen to edit your slide content.

We've spent a lot of time building things that look great and react quickly to clicks, but there's a silent failure point in most custom widgets: the keyboard. If you've ever tried to navigate a complex web app using only the Tab and Enter keys, you know exactly how frustrating it is when a "button" is actually just a div with a click listener. It's invisible to the keyboard, which means it's invisible to a huge segment of your users.

The "Click-Only" Trap

When I first started building custom UI components, my go-to move for a Tab switcher was simple. I'd make a list of div elements, style them to look like tabs, and attach a click event to each. In my head, it was elegant. The code was lean, and for me—using a mouse—it worked perfectly. I’d click "Settings," the content would swap, and I'd move on.

// The naive way: Just a click listener on a div
tabs.forEach(tab => {
  tab.addEventListener('click', () => {
    setActiveTab(tab);
  });
});

The problem is that div elements aren't focusable by default. If a user hits the Tab key to move through your page, the browser skips right over your custom tabs. They might as well not exist. Even if you add tabindex="0" to make them focusable, you've only solved half the problem. A user can now reach the tab, but hitting "Enter" or "Space" won't do anything because you only listened for click. While some browsers synthesize a click event from an Enter key on a button, they don't do that for a div.

Why "Just Adding Tabindex" Isn't Enough

You might think the fix is just to make every tab focusable and add a keydown listener. But if you have five tabs, and the user has to Tab five times just to get past your navigation menu to reach the actual content, you've created a "tab trap" of tediousness. This is where we move from "making it work" to "making it professional."

In a standard accessible widget, the entire group of tabs should act as a single stop in the page's tab order. Once the user tabs into the tab list, they should use the arrow keys to move between the individual tabs. This is a pattern called "Roving Tabindex." I prefer this because it mirrors how native OS elements work; it keeps the page's main navigation flow lean while providing deep control within the widget.

Implementing Roving Tabindex and ARIA Roles

To do this right, we need to combine a few things: the correct ARIA roles so screen readers know this is a tab list, and a bit of logic to manage which element is currently "reachable."

First, we give the container role="tablist" and each tab role="tab". Then, we set tabindex="0" on the active tab and tabindex="-1" on all the others. An element with -1 can be focused programmatically via JavaScript, but the user can't Tab into it. This ensures that hitting Tab only lands the user on the currently selected tab.

// The professional way: Managing focus and keys
const tabs = document.querySelectorAll('[role="tab"]');

tabs.forEach((tab, index) => {
  tab.addEventListener('keydown', (e) => {
    let newIndex = null;

    if (e.key === 'ArrowRight') {
      newIndex = (index + 1) % tabs.length;
    } else if (e.key === 'ArrowLeft') {
      newIndex = (index - 1 + tabs.length) % tabs.length;
    }

    if (newIndex !== null) {
      e.preventDefault(); // Stop the page from scrolling
      tabs[newIndex].focus();
      // Update tabindex: current becomes -1, new becomes 0
      tab.setAttribute('tabindex', '-1');
      tabs[newIndex].setAttribute('tabindex', '0');
      setActiveTab(tabs[newIndex]);
    }
  });
});

I've added e.preventDefault() here because, by default, the arrow keys scroll the browser window. If your user is trying to navigate your tabs and the whole page starts sliding up and down, it feels broken. By handling the focus programmatically and updating the tabindex on the fly, we've created a widget that feels native and respects the user's input method.




📋 Practical Task

Build a Keyboard-Accessible Tab Switcher

Create a custom Tab component consisting of three tabs (e.g., "Profile", "Account", "Notifications") and three corresponding content panels. Your implementation must satisfy these specific requirements:

  • ARIA Roles: The container must have role="tablist" and each trigger must have role="tab".
  • Roving Tabindex: Only the active tab should have tabindex="0". All inactive tabs must have tabindex="-1".
  • Arrow Navigation: Users must be able to move focus between tabs using the Left and Right arrow keys.
  • Focus Sync: When a user moves focus using the arrow keys, the corresponding content panel must be displayed, and the tabindex must be updated so the new tab is the only one reachable via the Tab key.
  • Activation: Ensure that both clicking a tab and pressing "Enter" or "Space" while a tab is focused activates that tab.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.