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
Tag | Element | Description |
---|---|---|
role="tablist" | Parent | Indicates a list of tab elements, which are references to tabpanel elements |
aria-label | Parent | Tabs label |
role="tab" | Button | Indicates an interactive element inside a tablist that, when activated, displays its associated tabpanel |
aria-selected | Button | Set as "true" or "false" to indicate the current active tab |
aria-controls | Button | Set to the corresponding panel id to indicate the relationship between the two |
id | Button | Set a unique ID for each button and panel |
tabIndex | Button | Set as 0 or -1 depending on the active tab |
tabIndex | Panel | Set as 0 or -1 depending on the active tab |
role="tabpanel" | Panel | Indicates an element that contains the content associated with a tab |
id | Panel | Set a unique ID for each button and panel |
aria-labelledby | Panel | Set 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> ); }