Loading...

Skip to main content

Accessible tab panels in React

Tabs are used to display a set of sections of content one at a time. Each panel of content ("tab panel") has an associated button, that when activated, displays the panel. The WAI-ARIA Authoring Practices(Opens in a new tab) include guidance on creating accessible tab components. We'll need to use the appropriate tags and WAI-ARIA roles, as well as giving users the ability to navigate between tabs with their keyboard arrows, the Home key, and the End key.

Adding refs to the buttons

In order to move focus between the buttons, we need to add refs.

Tabs.tsx
const tabsRefs = useRef<Array<HTMLButtonElement | null>>([]); return ( <ul role="tablist"> {tabs.map((tab, tabIndex) => ( <li key={`${tab.label}-tab`}> <button ref={(el: HTMLButtonElement) => (tabsRefs.current[tabIndex] = el)} > {tab.label} </button> </li> ))} </ul> );

Updating the active tab

When updating the active tab, we use the refs to move focus to the corresponding button. We'll also need to change some of the buttons props depending on their current state so we'll store the active tab index with useState.

Tabs.tsx
const [activeTabIndex, setActiveTabIndex] = useState<number>(0); const activateTab = (newTabIndex: number) => { /* Focus new tab button */ tabsRefs?.current[newTabIndex]?.focus(); /* Set new tab as active */ setActiveTabIndex(newTabIndex); };

Handle key press

First, we add an onKeyDown event listener to our buttons.

Tabs.tsx
<button onKeyDown={(event: KeyboardEvent<HTMLButtonElement>) => onKeyPressed(event, tabIndex) } > {tab.label} </button>


Here's our onKeyPress function. If the tab list has its aria-orientation set to vertical, replace ArrowRight and ArrowLeft with ArrowDown and ArrowUp:

Tabs.tsx
const onKeyPressed = ( event: KeyboardEvent<HTMLButtonElement>, tabIndex: number ) => { const shouldGoToNextTab = event.key === "ArrowRight"; const shouldGoToPreviousTab = event.key === "ArrowLeft"; const shouldGoToFirstTab = event.key === "Home"; const shouldGoToLastTab = event.key === "End"; const prevTab = tabIndex - 1; const nextTab = tabIndex + 1; const lastTab = totalTabs - 1; if (shouldGoToNextTab) { /* If the current active tab is the last, go the the first tab */ if (tabIndex >= totalTabs - 1) { activateTab(0); } else { activateTab(nextTab); } } else if (shouldGoToPreviousTab) { /* If the current active tab is the first, go the the last tab */ if (tabIndex <= 0) { activateTab(lastTab); } else { activateTab(prevTab); } } else if (shouldGoToFirstTab) { activateTab(0); } else if (shouldGoToLastTab) { activateTab(lastTab); } else { return null; } };

Tags and WAI-ARIA Roles

TagElementDescription
role="tablist"ParentIndicates a list of tab elements, which are references to tabpanel elements
aria-labelParentTabs label
role="tab"ButtonIndicates an interactive element inside a tablist that, when activated, displays its associated tabpanel
aria-selectedButtonSet as "true" or "false" to indicate the current active tab
aria-controlsButtonSet to the corresponding panel id to indicate the relationship between the two
idButtonSet a unique ID for each button and panel
tabIndexButtonSet as 0 or -1 depending on the active tab
tabIndexPanelSet as 0 or -1 depending on the active tab
role="tabpanel"PanelIndicates an element that contains the content associated with a tab
idPanelSet a unique ID for each button and panel
aria-labelledbyPanelSet to the corresponding button id to indicate the relationship between the two

WAI-ARIA Guidelines

  • When focus moves into the tab list, pressing the Tab key places focus on the active tab element.
  • When the tab list contains the focus, pressing the Tab key moves focus to the next element in the tab sequence, which is the tabpanel element.
  • Pressing the right arrow while a link is focused moves focus to the next tab. If focus is on the last tab, moves focus to the first tab. The newly focused tab is active.
  • Pressing the left arrow while a link is focused moves focus to the previous tab. If focus is on the first tab, moves focus to the last tab. The newly focused tab is active.
  • Pressing the Home key when a tab has focus moves focus to the first tab.
  • Pressing the End key when a tab has focus moves focus to the last tab.

View design pattern(Opens in a new tab)

Demo

import { useRef, useState, KeyboardEvent } from "react";
import { tabs } from "./constants";

import "./styles/styles.scss";

export default function App() {
  const [activeTabIndex, setActiveTabIndex] = useState<number>(0);
  const tabsRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const totalTabs = tabs.length;

  const activateTab = (newTabIndex: number) => {
    /* Focus new tab button */
    tabsRefs?.current[newTabIndex]?.focus();
    /* Set new tab as active */
    setActiveTabIndex(newTabIndex);
  };

  const onKeyPressed = (
    event: KeyboardEvent<HTMLButtonElement>,
    tabIndex: number
  ) => {
    const shouldGoToNextTab = event.key === "ArrowRight";
    const shouldGoToPreviousTab = event.key === "ArrowLeft";
    const shouldGoToFirstTab = event.key === "Home";
    const shouldGoToLastTab = event.key === "End";

    const prevTab = tabIndex - 1;
    const nextTab = tabIndex + 1;
    const lastTab = totalTabs - 1;

    if (shouldGoToNextTab) {
      if (tabIndex >= totalTabs - 1) {
        activateTab(0);
      } else {
        activateTab(nextTab);
      }
    } else if (shouldGoToPreviousTab) {
      if (tabIndex <= 0) {
        activateTab(lastTab);
      } else {
        activateTab(prevTab);
      }
    } else if (shouldGoToFirstTab) {
      activateTab(0);
    } else if (shouldGoToLastTab) {
      activateTab(lastTab);
    } else {
      return null;
    }
  };

  return (
    <div className="tabs">
      <ul role="tablist" aria-label="The Grand Budapest Hotel characters">
        {tabs.map((tab, tabIndex) => {
          const { id, label } = tab;
          return (
            <li key={`${id}-tab`}>
              <button
                type="button"
                role="tab"
                aria-selected={tabIndex === activeTabIndex ? "true" : "false"}
                aria-controls={`${id}-tab`}
                id={id}
                tabIndex={tabIndex === activeTabIndex ? 0 : -1}
                ref={(el: HTMLButtonElement) =>
                  (tabsRefs.current[tabIndex] = el)
                }
                onKeyDown={(event: KeyboardEvent<HTMLButtonElement>) =>
                  onKeyPressed(event, tabIndex)
                }
                onClick={() => activateTab(tabIndex)}
              >
                {label}
              </button>
            </li>
          );
        })}
      </ul>
      {tabs.map((tab, tabIndex) => {
        const { id, content } = tab;
        return (
          <div
            tabIndex={0}
            role="tabpanel"
            id={`${id}-tab`}
            key={`${id}-panel`}
            aria-labelledby={id}
            className={tabIndex === activeTabIndex ? "" : "isHidden"}
          >
            <p>{content}</p>
          </div>
        );
      })}
    </div>
  );
}