In this guide, we'll learn how to make tabs that everyone can use, including people who use screen readers. Let's build accessible tabs step by step!
What Do We Need?
First, we need to add these three roles to our tabs:
- Tab: The clickable button
- Tablist: The container for all tabs
- Tabpanel: The content area for each tab
Adding the Right Roles
Step 1: Add Tab Roles
Each <button>
element is a tab. Let's add role="tab"
to each button:
import * as React from 'react'
function Tabs ({defaultValue, items}){
return (
<div class='wrapper'>
<div className="tabs">
{items.map(({ value:itemValue, label}) => (
<button key={itemValue} role="tab" >
{item.label}
</button>
))}
</div>
</div>
)
}
When someone uses a screen reader and tabs to a tab, they'll hear "Tab" instead of "Button".
Step 2: Add the Tablist Role
Tabs need to be inside an element with a tablist
role. Let's add role="tablist"
to the tabs container:
import * as React from 'react'
function Tabs ({defaultValue, items}){
return (
<div class='wrapper'>
<div className="tabs" role="tablist">
{items.map(({value:itemValue, label}) => (
<button key={itemValue} role="tab" >
{label}
</button>
))}
</div>
</div>
)
}
Now, screen readers will announce "Tab 1 of 3", "Tab 2 of 3", and "Tab 3 of 3" when users move between tabs.
Step 3: Name Your Tablist
Your tablist should have a name that tells users what it's for. Let's use "Programming languages" as the name:
import * as React from 'react'
function Tabs ({defaultValue, items}){
return (
<div class='wrapper'>
<div className="tabs"
role="tablist"
aria-label="Programming languages">
{items.map(({ value:itemValue, label}) => (
<button key={itemValue} role="tab" >
{label}
</button>
))}
</div>
{/* tab content would be here */}
</div>
)
}
Step 4: Add Tabpanel Roles
The tabpanel
role tells screen reader users they're looking at tab content:
import * as React from 'react'
function Tabs ({defaultValue, items}){
return (
<div class='wrapper'>
<div className="tabs"
role="tablist"
aria-label="Programming languages">
{items.map(({ value:itemValue, label}) => (
<button key={itemValue} role="tab" >
{label}
</button>
))}
</div>
<div className="tabpanels">
{items.map(({ panel, value: itemValue }) => (
<div
key={itemValue}
role="tabpanel"
>
{panel}
</div>
))}
</div>
</div>
)
}
Step 5: Name Your Tabpanels
Each tabpanel should have a name that matches its tab:
import * as React from 'react'
function getTabListItemId(tabsId, value) {
return tabsId + '-tab-' + value;
}
function getTabPanelId(tabsId, value) {
return tabsId + '-tabpanel-' + value;
}
function Tabs ({defaultValue, items}){
const tabsId = React.useId();
return (
<div class='wrapper'>
<div className="tabs"
role="tablist"
aria-label="Programming languages">
{items.map(({ value:itemValue, label}) => (
<button
key={itemValue}
role="tab"
id={getTabListItemId(tabsId, itemValue)} >
{label}
</button>
))}
</div>
<div className="tabpanels">
{items.map(({ panel, value: itemValue }) => (
<div
key={itemValue}
role="tabpanel"
aria-labelledby={getTabListItemId(tabsId, itemValue)}
id={getTabPanelId(tabsId, itemValue)}
>
{panel}
</div>
))}
</div>
</div>
)
}
Making Navigation Work
Step 1: Make Tabpanels Focusable
We need to let users move to the tab content with their keyboard:
import * as React from 'react'
function getTabListItemId(tabsId, value) {
return tabsId + '-tab-' + value;
}
function getTabPanelId(tabsId, value) {
return tabsId + '-tabpanel-' + value;
}
function Tabs ({defaultValue, items}){
const tabsId = React.useId();
const [value, setValue] = React.useState(defaultValue ?? items[0]?.value);
return (
<div class='wrapper'>
<div className="tabs"
role="tablist"
aria-label="Programming languages">
{items.map(({ value:itemValue, label}) => (
<button
key={itemValue}
role="tab"
id={getTabListItemId(tabsId, itemValue)} >
{label}
</button>
))}
</div>
<div className="tabpanels">
{items.map(({ panel, value: itemValue }) => (
<div
key={itemValue}
role="tabpanel"
aria-labelledby={getTabListItemId(tabsId, itemValue)}
id={getTabPanelId(tabsId, itemValue)}
// this will make the tabpanel focusable
tabIndex={0}
hidden={itemValue !== value}
>
{panel}
</div>
))}
</div>
</div>
)
}
Step 2: Add Keyboard Navigation
import * as React from 'react'
function getTabListItemId(tabsId, value) {
return tabsId + '-tab-' + value;
}
function getTabPanelId(tabsId, value) {
return tabsId + '-tabpanel-' + value;
}
function Tabs ({defaultValue, items}){
const tabsId = React.useId();
return (
<div class='wrapper'>
<div className="tabs"
role="tablist"
aria-label="Programming languages">
{items.map(({ value:itemValue, label}) => (
<button
key={itemValue}
role="tab"
id={getTabListItemId(tabsId, itemValue)}
// Without this, users would need to tab through ALL tabs to get the content of the selected Tab.
tabIndex={itemValue === value ? 0 : -1}
>
{label}
</button>
))}
</div>
<div className="tabpanels">
{items.map(({ panel, value: itemValue }) => (
<div
key={itemValue}
role="tabpanel"
aria-labelledby={getTabListItemId(tabsId, itemValue)}
id={getTabPanelId(tabsId, itemValue)}
hidden={itemValue !== value}
tabIndex={0}
>
{panel}
</div>
))}
</div>
</div>
)
}
We'll let users press Tab or The Arrows to move to the tab content:
import * as React from 'react'
function getTabListItemId(tabsId, value) {
return tabsId + '-tab-' + value;
}
function getTabPanelId(tabsId, value) {
return tabsId + '-tabpanel-' + value;
}
function Tabs ({defaultValue, items}){
const tabsId = React.useId();
const [value, setValue] = React.useState(defaultValue ?? items[0].value);
const changeValue = (idx)=>{
const newValue = items[idx].value;
setValue(newValue);
document.getElementById(getTabListItemId(tabsId, newValue)).focus();
}
return (
<div class='wrapper'>
<div className="tabs"
role="tablist"
aria-label="Programming languages"
onKeyDown={(event) => {
switch (event.code) {
case 'ArrowLeft': {
const index = items.findIndex(
({ value: itemValue }) =>
itemValue === value,
);
changeValue(
(index - 1 + items.length) % items.length,
);
break;
}
case 'ArrowRight': {
const index = items.findIndex(({ value: itemValue }) =>itemValue === value);
changeValue((index + 1) % items.length);
break;
}
case 'Home': {
changeValue(0);
break;
}
case 'End': {
changeValue(items.length - 1);
break;
}
default:
break;
}
}}>
{items.map(({ value:itemValue, label}) => (
<button
key={itemValue}
role="tab"
id={getTabListItemId(tabsId, itemValue)}
>
{/* for the button it's text content would be it's accessible name */}
{label}
</button>
))}
</div>
{/* tabs content goes here */}
</div>
)
}
Step 3: Show Which Tab is Selected
We need to tell screen readers which tab is currently selected and which tab panel it is controlling:
import * as React from 'react'
function getTabListItemId(tabsId, value) {
return tabsId + '-tab-' + value;
}
function getTabPanelId(tabsId, value) {
return tabsId + '-tabpanel-' + value;
}
function Tabs ({defaultValue, items}){
const tabsId = React.useId();
const [value, setValue] = React.useState(defaultValue ?? items[0].value);
const changeValue = (idx)=>{
const newValue = items[idx].value;
setValue(newValue);
document.getElementById(getTabListItemId(tabsId, newValue)).focus();
}
return (
<div class='wrapper'>
<div className="tabs" role="tablist"
aria-label="Programming languages"
{/* onKeyDown={(event) => {the code for keyboard navigation}} */}
>
{items.map(({ value:itemValue, label}) => (
<button
key={itemValue}
role="tab"
id={getTabListItemId(tabsId, itemValue)}
tabIndex={itemValue === value ? 0 : -1}
aria-selected={itemValue === value}
aria-controls={getTabPanelId(tabsId, itemValue)}
className={['tab', itemValue === value ? 'selected' : ''].join(' ')}
onClick={()=>{
setValue(itemValue);
}}
>
{label}
</button>
))}
</div>
{/* tabs content goes here */}
</div>
)
}
With these steps, you've created tabs that work for everyone, including people who use screen readers or keyboards!