Keyboard Accessible Tabs with React
Tags: react, webdev, a11y, html
Published: 2021-01-09 16:35:15


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 <kbd>Tab</kbd>-key for navigating. Maybe they use also <kbd>Enter</kbd>, and that's it. But that is not the case - there are different expectations for keyboard interactions and navigation.

[Tabs][2]-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](#patterns-in-keyboard-navigation)
- [What are Tab Lists?](#what-are-tab-lists)
- [The React Components for the Example](#the-react-components-for-the-example)
- [ARIA-roles, States, and Properties](#ariaroles-states-and-properties)
- [Keyboard Interaction for Tabs](#keyboard-interaction-for-tabs)
- [Interaction with Mouse](#interaction-with-mouse)
- [Implementation of Keyboard Interaction](#implementation-of-keyboard-interaction)
- [Wrap-up](#wrapup)
- [Resources](#resources)

## Patterns in Keyboard Navigation

There was a time when keyboard interaction on the web was limited to <kbd>Tab</kbd> and <kbd>Enter</kbd> 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][6] 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][1] 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 <kbd>Home</kbd> or <kbd>End</kbd> 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 <kbd>Enter</kbd> or <kbd>Space</kbd> 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.](https://dev-to-uploads.s3.amazonaws.com/i/v8tqv8kxpubbu8dmxry6.png)

## 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:

```react
// Tabs.tsx
const Tabs = () => ( 
   <section>
      <ul role="tablist" aria-label="List of Tabs">
        {// Tab components}
      </ul>
     {// Tab panels}
    </section>
)
```
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: 

```react
// Tab.tsx
const Tab = () => {
  return (
  <li role="presentation">
    <button role="tab">Tab title</button>
   </li>
  )
}
```

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:

```react
// 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>
  )
}
```

### 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][7].

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:

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

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:

- <kbd>Tab</kbd>: 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.
- <kbd>Left Arrow</kbd>: 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.
- <kbd>Right Arrow</kbd>: 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.
- <kbd>Enter</kbd> or <kbd>Space bar</kbd>: 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. 
- <kbd>Shift</kbd> + <kbd>F10</kbd>: 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: 

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

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

```react
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>
  )
}
```

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

```react
// 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>
  )
}
```
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: <kbd>Tab</kbd>, <kbd>Left Arrow</kbd> and <kbd>Right Arrow</kbd>. As there is a `<button>`-element used for the tabs, behaviour for <kbd>Tab</kbd> 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:

```react
// Tab.tsx
...
      <button
        ...
        tabIndex={selectedTab === index ? 0 : -1}
      >
        {title}
      </button>
```

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][3].

There is still the arrow-keys' behavior to be implemented.  For this, React provides a useful tool: [Refs][4].  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:

```react 
// 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)
        },
  } 
```

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: 

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

```react
// Tabs.tsx

...

<Tab
   ...
   tabIndex={1}
   tabRef={tabValues[1].ref}
   title={tabValues[1].title}
/>
```

**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: 

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

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:

```react
// 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>

)
```

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 <kbd>Tab</kbd>-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 <kbd>Tab</kbd>-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'][2] Design Patterns.

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

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][8]
[`tabIndex`-attribute][3]
[`hidden`-attribute][7]
[Refs and the DOM - ReactJS][4]
[Using ARIA Keyboard Patterns for Interactive Widgets - Deque University][1]

[1]: https://dequeuniversity.com/tips/aria-keyboard-patterns
[2]: https://www.w3.org/TR/wai-aria-practices-1.1/#tabpanel
[3]: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
[4]: https://reactjs.org/docs/refs-and-the-dom.html
[5]: https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_generalnav
[6]: https://www.w3.org/TR/wai-aria-practices-1.1/#aria_ex
[7]: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/hidden
[8]: https://www.w3.org/TR/wai-aria-practices-1.1/
[9]: https://accessible-react.eevis.codes/components/tabs#source-code
[10]: https://accessible-react.eevis.codes/components/tabs