Make the accessible accordion keyboard-navigable per the ARIA Authoring Practices Guide: Arrow Up/Down move focus between the section headers, and Home/End jump to the first/last. Crucially — and unlike tabs — every header stays a normal Tab stop; the arrows are an addition on top of Tab, not a replacement.
// A self-contained component. No props.
function App(): JSX.Element;
The accessible accordion from before, now with Arrow/Home/End focus movement between headers.
focus a header, ArrowDown → focus next header (wraps last → first)
ArrowUp → focus previous header (wraps first → last)
Home / End → first / last header
Tab → still moves through headers normally (no roving)
Enter / Space → toggle the focused section (native button behaviour)
Arrows MOVE FOCUS between headers; they do not open/close.
Opening is still click / Enter / Space on the focused header.
Tab-reachable). This is the key difference from tabs..focus() on the target; preventDefault the handled keys so the page doesn't scroll.You'll add the accordion's keyboard model from the ARIA guide: arrow keys (and Home/End) move focus between headers, layered on top of the normal Tab order — not replacing it.
Keyboard users can already Tab to each header and press Enter/Space to toggle. The APG recommends one enhancement: while focus is on a header, ArrowDown/Up should jump to the adjacent header, and Home/End to the ends — quick movement through a long accordion. The subtlety is that, unlike a tablist, an accordion does not use a roving tabindex: every header remains a Tab stop, because each is an independent control. The arrows are extra, not a replacement.
Keep the ARIA structure and toggle logic from II. Add a keydown handler on each header that maps ArrowDown/Up (with wraparound) and Home/End to a target index, then moves focus to that header via a ref. It changes focus only — never the open state. And you do not touch tabIndex: all headers stay 0/default, so Tab still visits each.
Reaching for the tabs solution and applying a roving tabindex here is the classic mistake:
<button
className="accordion-header"
tabIndex={isFocused ? 0 : -1} // ← wrong for an accordion
onKeyDown={onKey}
>
A roving tabindex makes the whole accordion a single Tab stop, so a keyboard user can no longer Tab to each section — they'd have to arrow through everything. That's right for a tablist (one selected thing) but wrong for an accordion, where each header is independently operable and expected in the tab order. The fix is to leave the tab order alone and add arrow handling.
import { type KeyboardEvent, useRef, useState } from 'react';
import './styles.css';
const sections = [
{ id: 'html', title: 'HTML', body: 'HTML (HyperText Markup Language) describes the structure of a page using a system of tags.' },
{ id: 'css', title: 'CSS', body: 'CSS (Cascading Style Sheets) describes how HTML elements are rendered on screen.' },
{ id: 'js', title: 'JavaScript', body: 'JavaScript is the programming language of the web, used to add interactivity to pages.' },
];
export default function App() {
const [openIds, setOpenIds] = useState(new Set(['html']));
const headerRefs = useRef<(HTMLButtonElement | null)[]>([]);
function toggle(id: string) {
setOpenIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function onKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
let next = null;
if (e.key === 'ArrowDown') next = (index + 1) % sections.length;
else if (e.key === 'ArrowUp') next = (index - 1 + sections.length) % sections.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = sections.length - 1;
if (next === null) return;
e.preventDefault();
headerRefs.current[next]?.focus(); // move focus only — not open state
}
return (
<main>
<h1>Accordion III</h1>
<div className="accordion">
{sections.map((s, i) => {
const isOpen = openIds.has(s.id);
return (
<div className="accordion-section" key={s.id}>
<h3 className="accordion-heading">
<button
ref={(element) => {
headerRefs.current[i] = element;
}}
className="accordion-header"
id={`header-${s.id}`}
onClick={() => toggle(s.id)}
onKeyDown={(e) => onKeyDown(e, i)}
aria-expanded={isOpen}
aria-controls={`panel-${s.id}`}
>
<span>{s.title}</span>
<span className="accordion-chevron" data-open={isOpen}>›</span>
</button>
</h3>
{isOpen && (
<div className="accordion-body" id={`panel-${s.id}`} role="region" aria-labelledby={`header-${s.id}`}>
{s.body}
</div>
)}
</div>
);
})}
</div>
</main>
);
}
The additions over II: a headerRefs array, and an onKeyDown on each header that computes the target index for Arrow/Home/End (with wraparound) and calls .focus() on that header. No tabIndex changes — every header stays tabbable — and the open state is untouched by the arrows. Toggling is still the button's native click/Enter/Space.
Focus is on the HTML header (index 0):
onKeyDown(e, 0) computes next = (0 + 1) % 3 = 1, calls preventDefault, and headerRefs.current[1].focus() — focus moves to the CSS header. The open state is unchanged.next = 2; focus jumps to the JavaScript header.next = (2 + 1) % 3 = 0; focus wraps to HTML.onClick → toggle('html'), opening/closing it. Press Tab instead and focus moves to the next header normally — the tab order was never altered.Arrows give fast movement between headers; Tab and Enter/Space behave exactly as a keyboard user expects from buttons.
tabIndex alone; only .focus() on arrows.focus(); toggling stays on click/Enter/Space.preventDefault. Arrow keys scroll the page under the moving focus. Fix: preventDefault for the handled keys.Set/id so opening one closes the others.import { type KeyboardEvent, useState } from 'react';
import './styles.css';
const sections = [
{ id: 'html', title: 'HTML', body: 'HTML (HyperText Markup Language) describes the structure of a page using a system of tags.' },
{ id: 'css', title: 'CSS', body: 'CSS (Cascading Style Sheets) describes how HTML elements are rendered on screen.' },
{ id: 'js', title: 'JavaScript', body: 'JavaScript is the programming language of the web, used to add interactivity to pages.' },
];
export default function App() {
const [openIds, setOpenIds] = useState<Set<string>>(new Set(['html']));
function toggle(id: string) {
setOpenIds((current) => {
const next = new Set(current);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function onKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
const headers = Array.from(document.querySelectorAll<HTMLButtonElement>('.accordion-header'));
const index = headers.indexOf(event.currentTarget);
let next: number | null = null;
if (event.key === 'ArrowDown') next = (index + 1) % headers.length;
else if (event.key === 'ArrowUp') next = (index - 1 + headers.length) % headers.length;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = headers.length - 1;
if (next === null) return;
event.preventDefault();
headers[next].focus();
}
return (
<main>
<h1>Accordion III</h1>
<div className="accordion">
{sections.map((section) => {
const isOpen = openIds.has(section.id);
return (
<div className="accordion-section" key={section.id}>
<h3 className="accordion-heading">
<button className="accordion-header" id={`header-${section.id}`}
onClick={() => toggle(section.id)} onKeyDown={onKeyDown}
aria-expanded={isOpen} aria-controls={`panel-${section.id}`}>
<span>{section.title}</span>
<span className="accordion-chevron" data-open={isOpen}>›</span>
</button>
</h3>
{isOpen && (
<div className="accordion-body" id={`panel-${section.id}`}
role="region" aria-labelledby={`header-${section.id}`}>
{section.body}
</div>
)}
</div>
);
})}
</div>
</main>
);
}import { type KeyboardEvent, useRef, useState } from 'react';
import './styles.css';
const sections = [
{ id: 'html', title: 'HTML', body: 'HTML (HyperText Markup Language) describes the structure of a page using a system of tags.' },
{ id: 'css', title: 'CSS', body: 'CSS (Cascading Style Sheets) describes how HTML elements are rendered on screen.' },
{ id: 'js', title: 'JavaScript', body: 'JavaScript is the programming language of the web, used to add interactivity to pages.' },
];
function useAccordionKeyboard(count: number) {
const headers = useRef<(HTMLButtonElement | null)[]>([]);
const register = (index: number) => (element: HTMLButtonElement | null) => {
headers.current[index] = element;
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let next: number | null = null;
if (event.key === 'ArrowDown') next = (index + 1) % count;
else if (event.key === 'ArrowUp') next = (index - 1 + count) % count;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = count - 1;
if (next === null) return;
event.preventDefault();
headers.current[next]?.focus();
};
return { register, onKeyDown };
}
export default function App() {
const [open, setOpen] = useState<Record<string, boolean>>({ html: true });
const keyboard = useAccordionKeyboard(sections.length);
return (
<main>
<h1>Accordion III</h1>
<div className="accordion">
{sections.map((section, index) => {
const isOpen = Boolean(open[section.id]);
return (
<div className="accordion-section" key={section.id}>
<h3 className="accordion-heading">
<button ref={keyboard.register(index)} className="accordion-header"
id={`header-${section.id}`}
onClick={() => setOpen((current) => ({ ...current, [section.id]: !current[section.id] }))}
onKeyDown={(event) => keyboard.onKeyDown(event, index)}
aria-expanded={isOpen} aria-controls={`panel-${section.id}`}>
<span>{section.title}</span>
<span className="accordion-chevron" data-open={isOpen}>›</span>
</button>
</h3>
{isOpen && (
<div className="accordion-body" id={`panel-${section.id}`}
role="region" aria-labelledby={`header-${section.id}`}>
{section.body}
</div>
)}
</div>
);
})}
</div>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Make the accessible accordion keyboard-navigable per the ARIA Authoring Practices Guide: Arrow Up/Down move focus between the section headers, and Home/End jump to the first/last. Crucially — and unlike tabs — every header stays a normal Tab stop; the arrows are an addition on top of Tab, not a replacement.
// A self-contained component. No props.
function App(): JSX.Element;
The accessible accordion from before, now with Arrow/Home/End focus movement between headers.
focus a header, ArrowDown → focus next header (wraps last → first)
ArrowUp → focus previous header (wraps first → last)
Home / End → first / last header
Tab → still moves through headers normally (no roving)
Enter / Space → toggle the focused section (native button behaviour)
Arrows MOVE FOCUS between headers; they do not open/close.
Opening is still click / Enter / Space on the focused header.
Tab-reachable). This is the key difference from tabs..focus() on the target; preventDefault the handled keys so the page doesn't scroll.You'll add the accordion's keyboard model from the ARIA guide: arrow keys (and Home/End) move focus between headers, layered on top of the normal Tab order — not replacing it.
Keyboard users can already Tab to each header and press Enter/Space to toggle. The APG recommends one enhancement: while focus is on a header, ArrowDown/Up should jump to the adjacent header, and Home/End to the ends — quick movement through a long accordion. The subtlety is that, unlike a tablist, an accordion does not use a roving tabindex: every header remains a Tab stop, because each is an independent control. The arrows are extra, not a replacement.
Keep the ARIA structure and toggle logic from II. Add a keydown handler on each header that maps ArrowDown/Up (with wraparound) and Home/End to a target index, then moves focus to that header via a ref. It changes focus only — never the open state. And you do not touch tabIndex: all headers stay 0/default, so Tab still visits each.
Reaching for the tabs solution and applying a roving tabindex here is the classic mistake:
<button
className="accordion-header"
tabIndex={isFocused ? 0 : -1} // ← wrong for an accordion
onKeyDown={onKey}
>
A roving tabindex makes the whole accordion a single Tab stop, so a keyboard user can no longer Tab to each section — they'd have to arrow through everything. That's right for a tablist (one selected thing) but wrong for an accordion, where each header is independently operable and expected in the tab order. The fix is to leave the tab order alone and add arrow handling.
import { type KeyboardEvent, useRef, useState } from 'react';
import './styles.css';
const sections = [
{ id: 'html', title: 'HTML', body: 'HTML (HyperText Markup Language) describes the structure of a page using a system of tags.' },
{ id: 'css', title: 'CSS', body: 'CSS (Cascading Style Sheets) describes how HTML elements are rendered on screen.' },
{ id: 'js', title: 'JavaScript', body: 'JavaScript is the programming language of the web, used to add interactivity to pages.' },
];
export default function App() {
const [openIds, setOpenIds] = useState(new Set(['html']));
const headerRefs = useRef<(HTMLButtonElement | null)[]>([]);
function toggle(id: string) {
setOpenIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function onKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
let next = null;
if (e.key === 'ArrowDown') next = (index + 1) % sections.length;
else if (e.key === 'ArrowUp') next = (index - 1 + sections.length) % sections.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = sections.length - 1;
if (next === null) return;
e.preventDefault();
headerRefs.current[next]?.focus(); // move focus only — not open state
}
return (
<main>
<h1>Accordion III</h1>
<div className="accordion">
{sections.map((s, i) => {
const isOpen = openIds.has(s.id);
return (
<div className="accordion-section" key={s.id}>
<h3 className="accordion-heading">
<button
ref={(element) => {
headerRefs.current[i] = element;
}}
className="accordion-header"
id={`header-${s.id}`}
onClick={() => toggle(s.id)}
onKeyDown={(e) => onKeyDown(e, i)}
aria-expanded={isOpen}
aria-controls={`panel-${s.id}`}
>
<span>{s.title}</span>
<span className="accordion-chevron" data-open={isOpen}>›</span>
</button>
</h3>
{isOpen && (
<div className="accordion-body" id={`panel-${s.id}`} role="region" aria-labelledby={`header-${s.id}`}>
{s.body}
</div>
)}
</div>
);
})}
</div>
</main>
);
}
The additions over II: a headerRefs array, and an onKeyDown on each header that computes the target index for Arrow/Home/End (with wraparound) and calls .focus() on that header. No tabIndex changes — every header stays tabbable — and the open state is untouched by the arrows. Toggling is still the button's native click/Enter/Space.
Focus is on the HTML header (index 0):
onKeyDown(e, 0) computes next = (0 + 1) % 3 = 1, calls preventDefault, and headerRefs.current[1].focus() — focus moves to the CSS header. The open state is unchanged.next = 2; focus jumps to the JavaScript header.next = (2 + 1) % 3 = 0; focus wraps to HTML.onClick → toggle('html'), opening/closing it. Press Tab instead and focus moves to the next header normally — the tab order was never altered.Arrows give fast movement between headers; Tab and Enter/Space behave exactly as a keyboard user expects from buttons.
tabIndex alone; only .focus() on arrows.focus(); toggling stays on click/Enter/Space.preventDefault. Arrow keys scroll the page under the moving focus. Fix: preventDefault for the handled keys.Set/id so opening one closes the others.import { type KeyboardEvent, useState } from 'react';
import './styles.css';
const sections = [
{ id: 'html', title: 'HTML', body: 'HTML (HyperText Markup Language) describes the structure of a page using a system of tags.' },
{ id: 'css', title: 'CSS', body: 'CSS (Cascading Style Sheets) describes how HTML elements are rendered on screen.' },
{ id: 'js', title: 'JavaScript', body: 'JavaScript is the programming language of the web, used to add interactivity to pages.' },
];
export default function App() {
const [openIds, setOpenIds] = useState<Set<string>>(new Set(['html']));
function toggle(id: string) {
setOpenIds((current) => {
const next = new Set(current);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function onKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
const headers = Array.from(document.querySelectorAll<HTMLButtonElement>('.accordion-header'));
const index = headers.indexOf(event.currentTarget);
let next: number | null = null;
if (event.key === 'ArrowDown') next = (index + 1) % headers.length;
else if (event.key === 'ArrowUp') next = (index - 1 + headers.length) % headers.length;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = headers.length - 1;
if (next === null) return;
event.preventDefault();
headers[next].focus();
}
return (
<main>
<h1>Accordion III</h1>
<div className="accordion">
{sections.map((section) => {
const isOpen = openIds.has(section.id);
return (
<div className="accordion-section" key={section.id}>
<h3 className="accordion-heading">
<button className="accordion-header" id={`header-${section.id}`}
onClick={() => toggle(section.id)} onKeyDown={onKeyDown}
aria-expanded={isOpen} aria-controls={`panel-${section.id}`}>
<span>{section.title}</span>
<span className="accordion-chevron" data-open={isOpen}>›</span>
</button>
</h3>
{isOpen && (
<div className="accordion-body" id={`panel-${section.id}`}
role="region" aria-labelledby={`header-${section.id}`}>
{section.body}
</div>
)}
</div>
);
})}
</div>
</main>
);
}import { type KeyboardEvent, useRef, useState } from 'react';
import './styles.css';
const sections = [
{ id: 'html', title: 'HTML', body: 'HTML (HyperText Markup Language) describes the structure of a page using a system of tags.' },
{ id: 'css', title: 'CSS', body: 'CSS (Cascading Style Sheets) describes how HTML elements are rendered on screen.' },
{ id: 'js', title: 'JavaScript', body: 'JavaScript is the programming language of the web, used to add interactivity to pages.' },
];
function useAccordionKeyboard(count: number) {
const headers = useRef<(HTMLButtonElement | null)[]>([]);
const register = (index: number) => (element: HTMLButtonElement | null) => {
headers.current[index] = element;
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let next: number | null = null;
if (event.key === 'ArrowDown') next = (index + 1) % count;
else if (event.key === 'ArrowUp') next = (index - 1 + count) % count;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = count - 1;
if (next === null) return;
event.preventDefault();
headers.current[next]?.focus();
};
return { register, onKeyDown };
}
export default function App() {
const [open, setOpen] = useState<Record<string, boolean>>({ html: true });
const keyboard = useAccordionKeyboard(sections.length);
return (
<main>
<h1>Accordion III</h1>
<div className="accordion">
{sections.map((section, index) => {
const isOpen = Boolean(open[section.id]);
return (
<div className="accordion-section" key={section.id}>
<h3 className="accordion-heading">
<button ref={keyboard.register(index)} className="accordion-header"
id={`header-${section.id}`}
onClick={() => setOpen((current) => ({ ...current, [section.id]: !current[section.id] }))}
onKeyDown={(event) => keyboard.onKeyDown(event, index)}
aria-expanded={isOpen} aria-controls={`panel-${section.id}`}>
<span>{section.title}</span>
<span className="accordion-chevron" data-open={isOpen}>›</span>
</button>
</h3>
{isOpen && (
<div className="accordion-body" id={`panel-${section.id}`}
role="region" aria-labelledby={`header-${section.id}`}>
{section.body}
</div>
)}
</div>
);
})}
</div>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.