Make the accessible tabs keyboard-navigable the way the ARIA Authoring Practices Guide specifies: the whole tablist is a single Tab stop, and once focus is on it, the arrow keys move between tabs (Home/End jump to the ends). This is the roving tabindex pattern — exactly one tab is tabbable at a time, and focus follows the selection.
// A self-contained component. No props.
function App(): JSX.Element;
The ARIA tabs from before, now with roving tabindex and Arrow/Home/End key handling.
Tab into the tablist → lands on the SELECTED tab (one stop, not three)
ArrowRight → next tab (wraps from last to first), focus + panel follow
ArrowLeft → previous tab (wraps from first to last)
Home / End → first / last tab
Tab again → leaves the tablist (into the panel), not the next tab
Roving tabindex: selected tab → tabIndex 0; all others → tabIndex -1
tabIndex={0}; the rest are -1. So Tab enters the tablist once (onto the selected tab) and the next Tab leaves it — arrows handle movement within.keydown, ArrowRight/Left change the selected index (wrapping), Home/End go to the ends; preventDefault so the page doesn't scroll.-1 element.You'll add the keyboard model the ARIA guide prescribes for tabs: one tab stop for the whole tablist (roving tabindex), arrow keys to move between tabs, and focus that follows the selection.
With plain buttons, Tab stops on every tab — three tabs, three stops — which is tedious and not how native tab widgets behave. The ARIA pattern is: Tab should enter the tablist once, landing on the selected tab; from there the arrow keys move between tabs, and Tab again jumps out to the panel. Achieving that needs a "roving tabindex" — only the active tab is in the tab order — plus key handling that moves the selection and carries focus along.
The selected index still drives everything, with two additions. First, roving tabindex: render tabIndex={0} on the selected tab and tabIndex={-1} on the rest, so only one tab is tabbable. Second, arrow handling on the tablist: compute the next index (wrapping for arrows, ends for Home/End), set it as selected, and move focus to that tab via a ref. Selection, focus, and the visible panel all advance together.
The ARIA tabs from II are reachable but navigate wrong:
<button role="tab" aria-selected={i === selected} onClick={() => setSelected(i)}>
{t.label}
</button>
Every tab is a normal button, so it's tabIndex={0} by default — Tab stops on all three, and the arrow keys do nothing. A keyboard user has to Tab past each tab to get to the content, and there's no way to move between tabs with arrows as the platform (and the APG) leads them to expect. You need to take the inactive tabs out of the tab order and handle arrows yourself.
import { useRef, useState } from 'react';
import './styles.css';
const tabs = [
{ id: 'overview', label: 'Overview', content: 'A quick tour of what the product does and who it is for.' },
{ id: 'pricing', label: 'Pricing', content: 'Simple per-seat pricing with a free tier and no hidden fees.' },
{ id: 'reviews', label: 'Reviews', content: 'What customers say after switching — rated 4.8 out of 5.' },
];
export default function App() {
const [selected, setSelected] = useState(0);
const tabRefs = useRef([]);
function select(next) {
setSelected(next);
tabRefs.current[next]?.focus(); // focus follows the selection
}
function onKeyDown(e) {
let next = null;
if (e.key === 'ArrowRight') next = (selected + 1) % tabs.length;
else if (e.key === 'ArrowLeft') next = (selected - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = tabs.length - 1;
if (next === null) return;
e.preventDefault();
select(next);
}
const active = tabs[selected];
return (
<main className="container">
<h1>Tabs III</h1>
<div className="tablist" role="tablist" aria-label="Product information" onKeyDown={onKeyDown}>
{tabs.map((t, i) => (
<button
key={t.id}
ref={(el) => (tabRefs.current[i] = el)}
role="tab"
id={`tab-${t.id}`}
aria-selected={i === selected}
aria-controls={`panel-${t.id}`}
tabIndex={i === selected ? 0 : -1}
className={i === selected ? 'tab selected' : 'tab'}
onClick={() => select(i)}
>
{t.label}
</button>
))}
</div>
<div
className="tabpanel"
role="tabpanel"
id={`panel-${active.id}`}
aria-labelledby={`tab-${active.id}`}
tabIndex={0}
>
{active.content}
</div>
</main>
);
}
The additions over II: tabIndex={i === selected ? 0 : -1} makes the tablist a single tab stop; an onKeyDown on the tablist maps Arrow/Home/End to the next index (with wraparound); and a shared select(next) both sets state and calls .focus() on the target tab via tabRefs. Mouse clicks go through the same select, so behaviour is consistent.
Focus is on the tablist's selected tab, selected = 0 (Overview):
onKeyDown computes next = (0 + 1) % 3 = 1, calls preventDefault, then select(1).select(1) runs. setSelected(1) updates state; tabRefs.current[1].focus() moves focus to the Pricing tab.aria-selected="true" and tabIndex={0}; Overview drops to tabIndex={-1}. The panel swaps to Pricing's content. Selection, focus, and panel moved together.next = 2; focus and selection jump to Reviews. Press Tab. Because only the selected tab is tabIndex={0}, Tab now leaves the tablist and lands on the panel (tabIndex={0}), not on another tab.The roving tabindex makes Tab treat the tablist as one stop, and the arrow handler does the in-list movement — exactly the APG model.
tabIndex={0}, so Tab stops on each. Fix: roving tabindex — 0 for selected, -1 for the rest.-1 element, so the next arrow press has no origin. Fix: .focus() the new tab (via a ref) whenever selection changes by keyboard.e.preventDefault() for the handled keys.aria-orientation="vertical".This version keeps React state minimal and uses each existing tab id as the focus handle. The rendered accessibility contract and automatic activation behavior stay unchanged.
import { useState } from 'react';
import './styles.css';
const tabs = [
{ id: 'overview', label: 'Overview', content: 'A quick tour of what the product does and who it is for.' },
{ id: 'pricing', label: 'Pricing', content: 'Simple per-seat pricing with a free tier and no hidden fees.' },
{ id: 'reviews', label: 'Reviews', content: 'What customers say after switching — rated 4.8 out of 5.' },
];
export default function App() {
const [selected, setSelected] = useState(0);
function select(next: number, moveFocus = false) {
setSelected(next);
if (moveFocus) document.getElementById(`tab-${tabs[next].id}`)?.focus();
}
function onKeyDown(event: React.KeyboardEvent) {
const moves: Record<string, number> = {
ArrowRight: (selected + 1) % tabs.length,
ArrowLeft: (selected - 1 + tabs.length) % tabs.length,
Home: 0,
End: tabs.length - 1,
};
if (!(event.key in moves)) return;
event.preventDefault();
select(moves[event.key], true);
}
const active = tabs[selected];
return (
<main className="container">
<h1>Tabs III</h1>
<div className="tablist" role="tablist" aria-label="Product information" onKeyDown={onKeyDown}>
{tabs.map((tab, index) => (
<button
key={tab.id}
role="tab"
id={`tab-${tab.id}`}
aria-selected={index === selected}
aria-controls={`panel-${tab.id}`}
tabIndex={index === selected ? 0 : -1}
className={index === selected ? 'tab selected' : 'tab'}
onClick={() => select(index)}
>
{tab.label}
</button>
))}
</div>
<div
className="tabpanel"
role="tabpanel"
id={`panel-${active.id}`}
aria-labelledby={`tab-${active.id}`}
tabIndex={0}
>
{active.content}
</div>
</main>
);
}A reducer centralizes selection transitions while a small effect moves focus after keyboard actions. Clicks select without forcing focus, so normal pointer behavior remains intact.
import { useEffect, useReducer, useRef } from 'react';
import './styles.css';
const tabs = [
{ id: 'overview', label: 'Overview', content: 'A quick tour of what the product does and who it is for.' },
{ id: 'pricing', label: 'Pricing', content: 'Simple per-seat pricing with a free tier and no hidden fees.' },
{ id: 'reviews', label: 'Reviews', content: 'What customers say after switching — rated 4.8 out of 5.' },
];
type State = { selected: number; focusVersion: number };
type Action = { type: 'click'; index: number } | { type: 'key'; key: string };
function reducer(state: State, action: Action): State {
if (action.type === 'click') return { ...state, selected: action.index };
let selected = state.selected;
if (action.key === 'ArrowRight') selected = (selected + 1) % tabs.length;
else if (action.key === 'ArrowLeft') selected = (selected - 1 + tabs.length) % tabs.length;
else if (action.key === 'Home') selected = 0;
else if (action.key === 'End') selected = tabs.length - 1;
else return state;
return { selected, focusVersion: state.focusVersion + 1 };
}
export default function App() {
const [state, dispatch] = useReducer(reducer, { selected: 0, focusVersion: 0 });
const refs = useRef<Array<HTMLButtonElement | null>>([]);
useEffect(() => {
if (state.focusVersion) refs.current[state.selected]?.focus();
}, [state.focusVersion, state.selected]);
function onKeyDown(event: React.KeyboardEvent) {
if (!['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
dispatch({ type: 'key', key: event.key });
}
const active = tabs[state.selected];
return (
<main className="container">
<h1>Tabs III</h1>
<div className="tablist" role="tablist" aria-label="Product information" onKeyDown={onKeyDown}>
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={(node) => { refs.current[index] = node; }}
role="tab"
id={`tab-${tab.id}`}
aria-selected={index === state.selected}
aria-controls={`panel-${tab.id}`}
tabIndex={index === state.selected ? 0 : -1}
className={index === state.selected ? 'tab selected' : 'tab'}
onClick={() => dispatch({ type: 'click', index })}
>
{tab.label}
</button>
))}
</div>
<div
className="tabpanel"
role="tabpanel"
id={`panel-${active.id}`}
aria-labelledby={`tab-${active.id}`}
tabIndex={0}
>
{active.content}
</div>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Make the accessible tabs keyboard-navigable the way the ARIA Authoring Practices Guide specifies: the whole tablist is a single Tab stop, and once focus is on it, the arrow keys move between tabs (Home/End jump to the ends). This is the roving tabindex pattern — exactly one tab is tabbable at a time, and focus follows the selection.
// A self-contained component. No props.
function App(): JSX.Element;
The ARIA tabs from before, now with roving tabindex and Arrow/Home/End key handling.
Tab into the tablist → lands on the SELECTED tab (one stop, not three)
ArrowRight → next tab (wraps from last to first), focus + panel follow
ArrowLeft → previous tab (wraps from first to last)
Home / End → first / last tab
Tab again → leaves the tablist (into the panel), not the next tab
Roving tabindex: selected tab → tabIndex 0; all others → tabIndex -1
tabIndex={0}; the rest are -1. So Tab enters the tablist once (onto the selected tab) and the next Tab leaves it — arrows handle movement within.keydown, ArrowRight/Left change the selected index (wrapping), Home/End go to the ends; preventDefault so the page doesn't scroll.-1 element.You'll add the keyboard model the ARIA guide prescribes for tabs: one tab stop for the whole tablist (roving tabindex), arrow keys to move between tabs, and focus that follows the selection.
With plain buttons, Tab stops on every tab — three tabs, three stops — which is tedious and not how native tab widgets behave. The ARIA pattern is: Tab should enter the tablist once, landing on the selected tab; from there the arrow keys move between tabs, and Tab again jumps out to the panel. Achieving that needs a "roving tabindex" — only the active tab is in the tab order — plus key handling that moves the selection and carries focus along.
The selected index still drives everything, with two additions. First, roving tabindex: render tabIndex={0} on the selected tab and tabIndex={-1} on the rest, so only one tab is tabbable. Second, arrow handling on the tablist: compute the next index (wrapping for arrows, ends for Home/End), set it as selected, and move focus to that tab via a ref. Selection, focus, and the visible panel all advance together.
The ARIA tabs from II are reachable but navigate wrong:
<button role="tab" aria-selected={i === selected} onClick={() => setSelected(i)}>
{t.label}
</button>
Every tab is a normal button, so it's tabIndex={0} by default — Tab stops on all three, and the arrow keys do nothing. A keyboard user has to Tab past each tab to get to the content, and there's no way to move between tabs with arrows as the platform (and the APG) leads them to expect. You need to take the inactive tabs out of the tab order and handle arrows yourself.
import { useRef, useState } from 'react';
import './styles.css';
const tabs = [
{ id: 'overview', label: 'Overview', content: 'A quick tour of what the product does and who it is for.' },
{ id: 'pricing', label: 'Pricing', content: 'Simple per-seat pricing with a free tier and no hidden fees.' },
{ id: 'reviews', label: 'Reviews', content: 'What customers say after switching — rated 4.8 out of 5.' },
];
export default function App() {
const [selected, setSelected] = useState(0);
const tabRefs = useRef([]);
function select(next) {
setSelected(next);
tabRefs.current[next]?.focus(); // focus follows the selection
}
function onKeyDown(e) {
let next = null;
if (e.key === 'ArrowRight') next = (selected + 1) % tabs.length;
else if (e.key === 'ArrowLeft') next = (selected - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = tabs.length - 1;
if (next === null) return;
e.preventDefault();
select(next);
}
const active = tabs[selected];
return (
<main className="container">
<h1>Tabs III</h1>
<div className="tablist" role="tablist" aria-label="Product information" onKeyDown={onKeyDown}>
{tabs.map((t, i) => (
<button
key={t.id}
ref={(el) => (tabRefs.current[i] = el)}
role="tab"
id={`tab-${t.id}`}
aria-selected={i === selected}
aria-controls={`panel-${t.id}`}
tabIndex={i === selected ? 0 : -1}
className={i === selected ? 'tab selected' : 'tab'}
onClick={() => select(i)}
>
{t.label}
</button>
))}
</div>
<div
className="tabpanel"
role="tabpanel"
id={`panel-${active.id}`}
aria-labelledby={`tab-${active.id}`}
tabIndex={0}
>
{active.content}
</div>
</main>
);
}
The additions over II: tabIndex={i === selected ? 0 : -1} makes the tablist a single tab stop; an onKeyDown on the tablist maps Arrow/Home/End to the next index (with wraparound); and a shared select(next) both sets state and calls .focus() on the target tab via tabRefs. Mouse clicks go through the same select, so behaviour is consistent.
Focus is on the tablist's selected tab, selected = 0 (Overview):
onKeyDown computes next = (0 + 1) % 3 = 1, calls preventDefault, then select(1).select(1) runs. setSelected(1) updates state; tabRefs.current[1].focus() moves focus to the Pricing tab.aria-selected="true" and tabIndex={0}; Overview drops to tabIndex={-1}. The panel swaps to Pricing's content. Selection, focus, and panel moved together.next = 2; focus and selection jump to Reviews. Press Tab. Because only the selected tab is tabIndex={0}, Tab now leaves the tablist and lands on the panel (tabIndex={0}), not on another tab.The roving tabindex makes Tab treat the tablist as one stop, and the arrow handler does the in-list movement — exactly the APG model.
tabIndex={0}, so Tab stops on each. Fix: roving tabindex — 0 for selected, -1 for the rest.-1 element, so the next arrow press has no origin. Fix: .focus() the new tab (via a ref) whenever selection changes by keyboard.e.preventDefault() for the handled keys.aria-orientation="vertical".This version keeps React state minimal and uses each existing tab id as the focus handle. The rendered accessibility contract and automatic activation behavior stay unchanged.
import { useState } from 'react';
import './styles.css';
const tabs = [
{ id: 'overview', label: 'Overview', content: 'A quick tour of what the product does and who it is for.' },
{ id: 'pricing', label: 'Pricing', content: 'Simple per-seat pricing with a free tier and no hidden fees.' },
{ id: 'reviews', label: 'Reviews', content: 'What customers say after switching — rated 4.8 out of 5.' },
];
export default function App() {
const [selected, setSelected] = useState(0);
function select(next: number, moveFocus = false) {
setSelected(next);
if (moveFocus) document.getElementById(`tab-${tabs[next].id}`)?.focus();
}
function onKeyDown(event: React.KeyboardEvent) {
const moves: Record<string, number> = {
ArrowRight: (selected + 1) % tabs.length,
ArrowLeft: (selected - 1 + tabs.length) % tabs.length,
Home: 0,
End: tabs.length - 1,
};
if (!(event.key in moves)) return;
event.preventDefault();
select(moves[event.key], true);
}
const active = tabs[selected];
return (
<main className="container">
<h1>Tabs III</h1>
<div className="tablist" role="tablist" aria-label="Product information" onKeyDown={onKeyDown}>
{tabs.map((tab, index) => (
<button
key={tab.id}
role="tab"
id={`tab-${tab.id}`}
aria-selected={index === selected}
aria-controls={`panel-${tab.id}`}
tabIndex={index === selected ? 0 : -1}
className={index === selected ? 'tab selected' : 'tab'}
onClick={() => select(index)}
>
{tab.label}
</button>
))}
</div>
<div
className="tabpanel"
role="tabpanel"
id={`panel-${active.id}`}
aria-labelledby={`tab-${active.id}`}
tabIndex={0}
>
{active.content}
</div>
</main>
);
}A reducer centralizes selection transitions while a small effect moves focus after keyboard actions. Clicks select without forcing focus, so normal pointer behavior remains intact.
import { useEffect, useReducer, useRef } from 'react';
import './styles.css';
const tabs = [
{ id: 'overview', label: 'Overview', content: 'A quick tour of what the product does and who it is for.' },
{ id: 'pricing', label: 'Pricing', content: 'Simple per-seat pricing with a free tier and no hidden fees.' },
{ id: 'reviews', label: 'Reviews', content: 'What customers say after switching — rated 4.8 out of 5.' },
];
type State = { selected: number; focusVersion: number };
type Action = { type: 'click'; index: number } | { type: 'key'; key: string };
function reducer(state: State, action: Action): State {
if (action.type === 'click') return { ...state, selected: action.index };
let selected = state.selected;
if (action.key === 'ArrowRight') selected = (selected + 1) % tabs.length;
else if (action.key === 'ArrowLeft') selected = (selected - 1 + tabs.length) % tabs.length;
else if (action.key === 'Home') selected = 0;
else if (action.key === 'End') selected = tabs.length - 1;
else return state;
return { selected, focusVersion: state.focusVersion + 1 };
}
export default function App() {
const [state, dispatch] = useReducer(reducer, { selected: 0, focusVersion: 0 });
const refs = useRef<Array<HTMLButtonElement | null>>([]);
useEffect(() => {
if (state.focusVersion) refs.current[state.selected]?.focus();
}, [state.focusVersion, state.selected]);
function onKeyDown(event: React.KeyboardEvent) {
if (!['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
dispatch({ type: 'key', key: event.key });
}
const active = tabs[state.selected];
return (
<main className="container">
<h1>Tabs III</h1>
<div className="tablist" role="tablist" aria-label="Product information" onKeyDown={onKeyDown}>
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={(node) => { refs.current[index] = node; }}
role="tab"
id={`tab-${tab.id}`}
aria-selected={index === state.selected}
aria-controls={`panel-${tab.id}`}
tabIndex={index === state.selected ? 0 : -1}
className={index === state.selected ? 'tab selected' : 'tab'}
onClick={() => dispatch({ type: 'click', index })}
>
{tab.label}
</button>
))}
</div>
<div
className="tabpanel"
role="tabpanel"
id={`panel-${active.id}`}
aria-labelledby={`tab-${active.id}`}
tabIndex={0}
>
{active.content}
</div>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.