DEV Community

loading...
Cover image for Keyboard Accessible Tabs with React

Keyboard Accessible Tabs with React

eevajonnapanula profile image Eevis (she/her) ・12 min read

Have you ever wondered how some custom widgets, such as accordions or tree views, should behave when navigating with only a keyboard? I had this assumption that keyboard-only users would just use the Tab-key for navigating. Maybe they use also Enter, and that's it. But that is not the case - there are different expectations for keyboard interactions and navigation.

Tabs-pattern from WAI-ARIA Authoring Practices is an excellent example of more complicated keyboard navigation. It uses arrow keys for navigating between the tabs. Before we dive into the tabbed interfaces' details, let's talk a bit about these navigation patterns in general.

Table of Contents

Patterns in Keyboard Navigation

There was a time when keyboard interaction on the web was limited to Tab and Enter keys. This was before ARIA came along. Maybe because of that, sometimes the assumption is that tabbing through the focusable items is the only way to navigate the web page with a keyboard. That is not the case anymore, and there are different patterns for navigating with a keyboard.

Design Patterns in WAI-ARIA Authoring Practices introduce different keyboard interaction patterns for various custom widgets, so be sure to check them. More general instructions are, as Deque University puts it:

In short, when it comes to widgets, the ARIA keyboard pattern should be this: users can tab to the widget, then use arrow keys within the widget.

Other keys, such as Home or End can be used, but the best practice is to use arrow keys for the navigation within the widget. One good example of this interaction is the Tabs / Tab Lists-pattern, which will be implemented in this blog post.

What are Tab Lists?

Tabs, or tab lists, are a set of sections of content shown one at a time. Each of them has a tab element that is associated with a section containing content. That tab element acts as a control for displaying the section related to it. These controls are on the edge of the visible section, and most commonly, at the top edge.

Tabs can be activated either automatically, so when the user moves focus to a tab, the tab panel associated with the tab is displayed. Another option is to let the user activate the tab with an Enter or Space key when they've focused on the tab.

The React Components for the Example

These example components are built with React and TypeScript, but the only TypeScript things in the code examples are the types in function parameters and the components and the tsx-file type. If you want to build these in JavaScript, use jsx in the file type, and omit the components' and function parameters' types. The React version used in the example is 17.0.1.

Three elements are needed to implement the tabs widget: Tab, TabPanel, and Tabs, the wrapper for the whole widget. Let's start building them and adding the ARIA-roles, states, and properties.

Three Tab-widgets showing each placement of a component: Tab Wrapper/Tab List, Tab and Tab Panel.

ARIA-roles, States, and Properties

Some ARIA-roles, states, and attributes need to be added to the elements of tabbed interfaces to make them accessible for screen reader users. Let's look into the elements, component by component.

Tabs-Component

First, we'll start with the Tabs component. It is a wrapper, and has two duties. It wraps the whole widget, and it contains a wrapper for a tab list. Maybe some code explains it better:

// Tabs.tsx
const Tabs = () => ( 
   <section>
      <ul role="tablist" aria-label="List of Tabs">
        {// Tab components}
      </ul>
     {// Tab panels}
    </section>
)
Enter fullscreen mode Exit fullscreen mode

The section-element serves as the wrapper for the whole widget, and then the tab list is wrapped with an ul-element, which needs to have the role of tablist. The tab list element also needs an accessible name. This could be added via aria-labelledby-attribute if there was a text to refer to. In the example, however, there is not, so the aria-label is used.

Another ARIA-attribute that could be added here is the aria-orientation for indicating the tabs' orientation. Value for it can be horizontal or vertical. It communicates which arrows (left/right or up/down) should be used for the navigation depending on the tab list's orientation. The default value is horizontal, and as our tab list is horizontal, it can be omitted and is not visible on the example.

Tab-Component

Each tab should have an item, which has the role of tab. In our example, that element is a button wrapped with a li-element. As the tab list is not a real list, we need to strip the semantics from the li-element with role="presentation". Here's the code:

// Tab.tsx
const Tab = () => {
  return (
  <li role="presentation">
    <button role="tab">Tab title</button>
   </li>
  )
}
Enter fullscreen mode Exit fullscreen mode

Additionally, the button handling the tab selection needs to have aria-states and properties. First, it requires the aria-controls-attribute referring to the tab panel element it controls. Also, only one of the tabs can be active at a time. This needs to be communicated with aria-selected-attribute. It is set true to the active tab and false to the others.

To implement these requirements, the parent component (Tabs) needs to pass some information down to the Tab-component. We'll implement that a little later.

The Tab-component needs to know about the currently selected tab, its own index, and the id of the tab panel it controls. The parent also passes a title and an id to the button. They will be needed for associating the Tab with TabPanel. Here's some code demonstrating these properties and their usage:

// Tab.tsx
const Tab = ({ id, index, selectedTab, tabPanelId, title }) => {
  return (
  <li role="presentation">
    <button 
      role="tab" 
      id={id}
      aria-selected={selectedTab === index}
      aria-controls={tabPanelId}
     >
      {title}
    </button>
   </li>
  )
}
Enter fullscreen mode Exit fullscreen mode

Tab Panel-Component

The tab panel component needs to have the role of tabpanel. It also requires an aria-labelledby-attribute to point to the button that controls it to give it an accessible name. Also, as there can only be one tab panel visible at a time, the others need to be hidden. In the example, we implement this with the hidden-attribute.

In React code, this means that the parent component needs to pass the tab panel's id (as the Tab needs it for the aria-controls), the id of the tab controlling the current tab panel. Also, the selected index and the index of the current tab panel need to be passed down.

If the tab panel doesn't have any focusable items or items in the tab sequence, a screen reader user may miss it. One way to solve this is to put the tab panel into the tab order with tabIndex={0}.

The TabPanel-component works as a container for the content, so one more thing to give to it as props is the children-props. This means, that everything that is wrapped inside the TabPanel-component is rendered inside the section-element it has. Here's how it can be done with code:

const TabPanel = ({ id, tabId, selectedTab, tabIndex, children }) => (
  <section
    role="tabpanel"
    id={id}
    aria-labelledby={tabId}
    hidden={selectedTab !== tabIndex}
    tabIndex={0}
  >
    {children}
  </section>
)
Enter fullscreen mode Exit fullscreen mode

ARIA attributes serve as a promise of the interaction, and the next thing to do is to actually implement what we promise our tabbed interface to do.

Keyboard Interaction for Tabs

In the example, only the required keyboard shortcuts are implemented. This means the following ones:

  • Tab: When focus moves to the tabs-widget, the active tab-element gets focus. When the focus is in the tab-element, the focus moves to the next focusable item (so, not to the next tab). This can mean either item in the active tab panel or first thing outside the widget.
  • Left Arrow: When the focus is on the active tab-element, the focus moves to the next tab on the list. If on the last tab, focus moves to the first tab. If tabs are automatically activated, activates the focused tab.
  • Right Arrow: When the focus is on the active tab-element, the focus moves to the previous tab on the list. If on the first item, moves focus to the last tab. If tabs are automatically activated, activates the focused tab.
  • Enter or Space bar: If tabs are not activated automatically when focused, activates the focused tab. In the example, tabs are activated automatically. As the example uses a button-element, we get these interactions for free.
  • Shift + F10: If there is a pop-up menu associated with the tab, this shortcut opens it. In this example, there is no pop-up menu, so this shortcut is not implemented.

Interaction with Mouse

What this means at the code level is that there are several custom handlers to be made. Tabs-panel needs some additions, and some handlers need to be passed down to the Tab-component. First, let's add the selectedTab, which got passed down in the ARIA-examples. For it, and some other things down the line, let's also define the tabs in an object, which has the tab's index as a key:

// Tabs.tsx
const Tabs = () => {
  const tabValues = {
    1: {
          title: "First tab"
        },
    2: {
          title: "Second tab"
        },
    3: {
          title: "Third tab"
        }
  } 
  const [selectedTab, setSelectedTab] = useState(1)
  return ( 
     {// ... }
  )
}
Enter fullscreen mode Exit fullscreen mode

With these in place, the click-handler is a short function, which we then pass down to the Tab-component:

const Tabs = () => {
  const tabValues = {
    1: {
          title: "First tab"
        },
    2: {
          title: "Second tab"
        },
    3: {
          title: "Third tab"
        },
  } 

  const [selectedTab, setSelectedTab] = useState(1)
  const handleClick = (index) => setSelectedTab(index) 

  return (   
    <section>
       <ul role="tablist">
         <Tab
           id="firstTab"
           tabPanelId="firstTabPanel"
           index={1}
           handleChange={handleClick}
           selectedTab={selectedTab}
           title={tabValues[1].title}
         />
           {// Rest of the tabs}
       </ul>
       <TabPanel
         id="firstTabPanel"
         tabId="firstTab"
         tabIndex={1}
         selectedTab={selectedTab}
       >
         First tab panel here
       </TabPanel>
       {// Rest of the tab panels}
     </section>
  )
}
Enter fullscreen mode Exit fullscreen mode

in the Tab-component, we need to add the following for the handler to work:

// Tab.tsx
const Tab = ({ 
  id, 
  index, 
  selectedTab, 
  tabPanelId, 
  title, 
  handleChange 
}) => {
  const handleClick = () => handleChange(index)
  return (
  <li role="presentation">
    <button 
      role="tab" 
      id={id}
      aria-selected={selectedTab === index}
      aria-controls={tabPanelId}
      onClick={handleClick}
     >
      {title}
    </button>
   </li>
  )
}
Enter fullscreen mode Exit fullscreen mode

This ensures that every time a user clicks the tab, the tab's index gets passed to the setSelectedTab-function.

Ok, now there is a working solution for the mouse users. What about the keyboard users and the interactions listed at the beginning of this section?

Implementation of Keyboard Interaction

As the tabs are activated automatically, and there is no pop-up menu, there are only three keyboard interactions to implement: Tab, Left Arrow and Right Arrow. As there is a <button>-element used for the tabs, behaviour for Tab is almost implemented. There is one thing, though - for tabbing to work correctly, only the selected tab should be focusable. This is handled with tabIndex-attribute:

// Tab.tsx
...
      <button
        ...
        tabIndex={selectedTab === index ? 0 : -1}
      >
        {title}
      </button>
Enter fullscreen mode Exit fullscreen mode

This way, if the current tab is selected, it is in the focus order (tabindex with value 0), and if not, it can be programmatically focused but is not in the focus order (value -1). You can read more about tabindex-attribute from MDN.

There is still the arrow-keys' behavior to be implemented. For this, React provides a useful tool: Refs. React documentation describes them with the following words:

Refs provide a way to access DOM nodes or React elements created in the render method.

We need to focus on the correct element programmatically when a user presses either one of the arrow keys. This can be done with refs. First, let's add these references to the object of tabValues we created:

// Tabs.tsx
import React, { useRef, useState } from "react";
....
  const tabValues = {
    1: {
          title: "First tab",
          ref: useRef(null)
        },
    2: {
          title: "Second tab",
          ref: useRef(null)
        },
    3: {
          title: "Third tab",
          ref: useRef(null)
        },
  } 
Enter fullscreen mode Exit fullscreen mode

With the useRef(null) a reference is initialized. Next, we add the reference to the Tab-component and pass it down to the correct component:

// Tab.tsx
...
const Tab: FunctionComponent<TabProps> = ({
  ...
  tabRef,
}) => {
  const handleClick = () => handleChange(tabIndex);
  return (
    <li role="presentation">
      <button
       ...
        ref={tabRef}
      >
        {title}
      </button>
    </li>
  );
};
export default Tab;
Enter fullscreen mode Exit fullscreen mode

and

// Tabs.tsx

...

<Tab
   ...
   tabIndex={1}
   tabRef={tabValues[1].ref}
   title={tabValues[1].title}
/>
Enter fullscreen mode Exit fullscreen mode

Something to note: For passing the reference down to a component, the prop-name of that reference needs to be something else than ref as it is reserved and causes errors.

Alright, now there is a way to access the buttons in the Tab-elements. Next, we implement the keypress event listeners for both left and right arrows. What is important here is that when the focus is on the first tab, and a user presses a left arrow key, the focus should next go to the last tab. This same principle applies when the focus is on the last tab, and a user presses the right arrow key - the focus should go to the first tab.

First, let's create a helper function to handle focusing the correct tab:

// Tabs.tsx
...
 const handleNextTab = (
    firstTabInRound: number,
    nextTab: number,
    lastTabInRound: number
  ) => {
    const tabToSelect =
      selectedTab === lastTabInRound ? firstTabInRound : nextTab;
    setSelectedTab(tabToSelect);
    tabValues[tabToSelect].ref.current.focus();
  };
Enter fullscreen mode Exit fullscreen mode

The function takes three parameters:

  • firstTabInRound: The number of the tab that is "first" in the round - with left arrow, this would be the last tab, and with the right arrow - the first.
  • nextTab: The tab where the focus should go next if the selected tab is not the last in the round.
  • lastTabInRound: "Last" tab in the round - with left arrow, this would be the first tab, and with the right arrow - the last.

First, the function checks which tab should be selected next. If the currently selected tab is the first or last tab (depending on the direction), the next tab would be the first tab in the round (so, first or last, depending on the direction). If not, then the next tab would be the following in order.

That tab (either the next or the first/last in the round) is set to the selected tab. The next thing to do is to actually give the focus to the selected tab. This is done with the reference of that tab component. From the tabValues-object, the tab, which is the newly selected tab, is retrieved with the tabValues[tabToSelect]. The ref is then used to focus on that tab with ref.current.focus().

This function is used by a keypress event handler:

// Tabs.tsx

....

  const handleKeyPress = (event) => {
    const tabCount = Object.keys(tabValues).length;

    if (event.key === "ArrowLeft") {
      const last = tabCount;
      const next = selectedTab - 1;
      handleNextTab(last, next, 1);
    }
    if (event.key === "ArrowRight") {
      const first = 1;
      const next = selectedTab + 1;
      handleNextTab(first, next, tabCount);
    }
  };

....

return (
 <section className="tabs-wrapper">
      <ul
        role="tablist"
        className="tablist"
        aria-label="Cat tabs"
        onKeyDown={handleKeyPress}
      >
        ...
      </ul>
      ...
  </section>

)
Enter fullscreen mode Exit fullscreen mode

In the handleKeyPress-function, we first check if the keypress is either left or right arrow. Next, we get the correct values to pass down to the helper-function. This means the first item in the round, the next tab in order, and the last item in the round.

You might wonder why the handleKeyPress is given to the ul-element instead of the Tab-elements. The reason is that we only want to capture arrow key events when the focus is inside that ul element. This also reduces the amount of code. However, it would work if the event was on the button inside the `Tab '-element.

After this, we can test the keyboard navigation. How to do this:

  1. Use Tab-key to get to the first button-element
  2. Then, use the left and right arrow keys to change the tab.
  3. See how the focus changes from tab to tab, and the correct tab panel should be visible.
  4. Use the Tab-key to getting away from the tabs. 5. The next focused item should be the tab panel and not the next tab-button.

Wrap-up

In this blog post, I've explained one way to build a keyboard accessible tab list with React. This has been done according to the WAI-ARIA Authoring practices' Design Patterns.

You can see an example of the tab lists in a site I created for showing the complete code for these blog posts. Here's also a direct link to the source code of the accordion component.

If you have any questions or comments, I'll be happy to answer! 😊 Also, if you find any errors in the code, I'd like to hear from them. πŸ˜„

Resources

WAI-ARIA Authoring Practices
tabIndex-attribute
hidden-attribute
Refs and the DOM - ReactJS
Using ARIA Keyboard Patterns for Interactive Widgets - Deque University

Discussion (0)

pic
Editor guide