Accordion Science: Crafting an Accessible Expand/Collapse Component in React
A practical, code-heavy walkthrough on building a robust accordion component in React, featuring full ARIA attribute management, advanced keyboard navigation, and strict TypeScript typing.
Accordion Science: Crafting an Accessible Expand/Collapse Component in React
When developers build accordions, they often stop at a basic toggle state. Click the header, change the height, flip an arrow icon—done. But from an accessibility perspective, a naive accordion is a labyrinth for screen reader users and keyboard-only operators.
To build a truly accessible accordion, we must adhere to the WAI-ARIA Authoring Practices Guide (APG). This means implementing proper ARIA roles, dynamic attribute management (aria-expanded, aria-controls, aria-labelledby), and seamless keyboard interactions including arrow key navigation, Home, and End keys.
In this post, we will construct a robust, production-ready accordion component in React using TypeScript.
The Anatomy of an Accessible Accordion
Before writing code, let’s understand the structural contract between our DOM elements and assistive technologies:
- The Header (
<button>): Must be a native<button>element to ensure built-in focus management and click handling. It requiresaria-expanded(indicating open/closed state) andaria-controls(pointing to the ID of the panel it controls). - The Panel (
<div>): Must have anidmatching the header’saria-controls, and anaria-labelledbypointing back to the header. It should also utilizerole="region"if it contains significant content. - Keyboard Navigation:
EnterorSpace: Toggles the focused accordion item.ArrowDown: Moves focus to the next accordion header.ArrowUp: Moves focus to the previous accordion header.Home: Moves focus to the first accordion header.End: Moves focus to the last accordion header.
Step 1: TypeScript Definitions
Let’s design a flexible API that supports both single-expansion (default) and multi-expansion modes. We’ll start by defining our TypeScript interfaces.
import React, { ReactNode } from 'react';
export type AccordionVariant = 'single' | 'multiple';
export interface AccordionProps {
/** Controls whether one or multiple panels can be expanded */
variant?: AccordionVariant;
/** Default expanded value(s) for uncontrolled usage */
defaultValue?: string | string[];
/** Controlled expanded value(s) */
value?: string | string[];
/** Callback fired when expansion state changes */
onChange?: (value: string | string[]) => void;
/** Accordion items */
children: ReactNode;
/** Additional CSS class */
className?: string;
}
export interface AccordionItemProps {
/** Unique identifier for the item */
value: string;
/** Header title text or custom node */
title: ReactNode;
/** Content revealed upon expansion */
children: ReactNode;
/** Disable this specific item */
disabled?: boolean;
/** Additional CSS class */
className?: string;
}
// Internal context type for parent-child communication
export interface AccordionContextType {
expandedValues: string[];
toggleItem: (value: string) => void;
registerHeader: (value: string, element: HTMLButtonElement | null) => void;
focusNextHeader: (currentValue: string) => void;
focusPrevHeader: (currentValue: string) => void;
focusFirstHeader: () => void;
focusLastHeader: () => void;
}
Step 2: Creating the Accordion Context
To coordinate focus and state across arbitrary component depths, React Context is indispensable. It will track which items are expanded and maintain references to all header DOM nodes for keyboard navigation.
import React, { createContext, useContext, useState, useRef, useCallback } from 'react';
const AccordionContext = createContext<AccordionContextType | null>(null);
export const useAccordion = () => {
const context = useContext(AccordionContext);
if (!context) {
throw new Error('Accordion compound components must be rendered within an Accordion component.');
}
return context;
};
Step 3: Implementing the Parent Accordion Component
Now we wire up state management, handling both controlled and uncontrolled patterns, alongside our keyboard traversal map.
export const Accordion: React.FC<AccordionProps> = ({
variant = 'single',
defaultValue,
value: controlledValue,
onChange,
children,
className,
}) => {
// Internal state for uncontrolled mode
const [uncontrolledValue, setUncontrolledValue] = useState<string[]>(() => {
if (defaultValue === undefined) return [];
return Array.isArray(defaultValue) ? defaultValue : [defaultValue];
});
// Determine if component is controlled
const isControlled = controlledValue !== undefined;
const rawValue = isControlled ? controlledValue : uncontrolledValue;
const expandedValues = Array.isArray(rawValue) ? rawValue : [rawValue];
// Registry of header DOM nodes for arrow navigation
const headerRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const registerHeader = useCallback((value: string, element: HTMLButtonElement | null) => {
if (element) {
headerRefs.current.set(value, element);
} else {
headerRefs.current.delete(value);
}
}, []);
const getEnabledHeaders = (): { value: string; element: HTMLButtonElement }[] => {
const items: { value: string; element: HTMLButtonElement }[] = [];
headerRefs.current.forEach((element, value) => {
if (!element.disabled) {
items.push({ value, element });
}
});
return items;
};
const focusNextHeader = (currentValue: string) => {
const enabled = getEnabledHeaders();
const index = enabled.findIndex(item => item.value === currentValue);
if (index !== -1 && index < enabled.length - 1) {
enabled[index + 1].element.focus();
} else if (index === enabled.length - 1 && enabled.length > 0) {
enabled[0].element.focus(); // Wrap around
}
};
const focusPrevHeader = (currentValue: string) => {
const enabled = getEnabledHeaders();
const index = enabled.findIndex(item => item.value === currentValue);
if (index > 0) {
enabled[index - 1].element.focus();
} else if (index === 0 && enabled.length > 0) {
enabled[enabled.length - 1].element.focus(); // Wrap around
}
};
const focusFirstHeader = () => {
const enabled = getEnabledHeaders();
if (enabled.length > 0) enabled[0].element.focus();
};
const focusLastHeader = () => {
const enabled = getEnabledHeaders();
if (enabled.length > 0) enabled[enabled.length - 1].element.focus();
};
const toggleItem = (targetValue: string) => {
let nextValues: string[];
if (variant === 'single') {
nextValues = expandedValues.includes(targetValue) ? [] : [targetValue];
} else {
nextValues = expandedValues.includes(targetValue)
? expandedValues.filter(v => v !== targetValue)
: [...expandedValues, targetValue];
}
if (!isControlled) {
setUncontrolledValue(nextValues);
}
if (onChange) {
onChange(variant === 'single' ? (nextValues[0] ?? '') : nextValues);
}
};
return (
<AccordionContext.Provider
value={{
expandedValues,
toggleItem,
registerHeader,
focusNextHeader,
focusPrevHeader,
focusFirstHeader,
focusLastHeader,
}}
>
<div className={`accordion ${className || ''}`}>
{children}
</div>
</AccordionContext.Provider>
);
};
Step 4: Implementing the AccordionItem Component
Each item encapsulates its own header and panel, applying the necessary accessibility wiring (aria-controls, aria-expanded, aria-labelledby, and keyboard event handlers).
export const AccordionItem: React.FC<AccordionItemProps> = ({
value,
title,
children,
disabled = false,
className,
}) => {
const {
expandedValues,
toggleItem,
registerHeader,
focusNextHeader,
focusPrevHeader,
focusFirstHeader,
focusLastHeader,
} = useAccordion();
const isExpanded = expandedValues.includes(value);
const headerId = `accordion-header-${value}`;
const panelId = `accordion-panel-${value}`;
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
focusNextHeader(value);
break;
case 'ArrowUp':
event.preventDefault();
focusPrevHeader(value);
break;
case 'Home':
event.preventDefault();
focusFirstHeader();
break;
case 'End':
event.preventDefault();
focusLastHeader();
break;
default:
break;
}
};
return (
<div className={`accordion-item ${disabled ? 'accordion-item--disabled' : ''} ${className || ''}`}>
<h3 className="accordion-header">
<button
ref={node => registerHeader(value, node)}
id={headerId}
type="button"
aria-expanded={isExpanded}
aria-controls={panelId}
disabled={disabled}
onClick={() => toggleItem(value)}
onKeyDown={handleKeyDown}
className="accordion-trigger"
>
<span className="accordion-title-text">{title}</span>
<span className={`accordion-icon ${isExpanded ? 'expanded' : ''}`}>
▼
</span>
</button>
</h3>
<div
id={panelId}
role="region"
aria-labelledby={headerId}
hidden={!isExpanded}
className="accordion-panel"
>
<div className="accordion-panel-content">
{children}
</div>
</div>
</div>
);
};
Pro-Tip: Notice the use of the native
hiddenHTML attribute combined with CSS or React’s conditional rendering. Using nativehidden(or CSSdisplay: none) removes the collapsed panel entirely from the accessibility tree so screen readers don’t read hidden content.
Step 5: Styling for Polish and State Transitions
To make our accordion feel snappy and professional, let’s add clean CSS styles supporting our states.
.accordion {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
}
.accordion-item + .accordion-item {
border-top: 1px solid #e2e8f0;
}
.accordion-trigger {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
background: #ffffff;
border: none;
font-size: 1rem;
font-weight: 600;
color: #1e293b;
cursor: pointer;
text-align: left;
transition: background-color 0.2s ease;
}
.accordion-trigger:hover:not(:disabled) {
background-color: #f8fafc;
}
.accordion-trigger:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: -2px;
}
.accordion-trigger:disabled {
color: #94a3b8;
cursor: not-allowed;
}
.accordion-icon {
transition: transform 0.2s ease;
font-size: 0.75rem;
}
.accordion-icon.expanded {
transform: rotate(180deg);
}
.accordion-panel {
padding: 0 1.25rem 1.25rem 1.25rem;
background: #ffffff;
color: #475569;
font-size: 0.95rem;
line-height: 1.5;
}
Step 6: Usage Example
Putting our newly crafted accessible accordion into action is remarkably straightforward.
export default function App() {
return (
<div style={{ maxWidth: '600px', margin: '2rem auto' }}>
<h2>System Settings</h2>
<Accordion defaultValue="general">
<AccordionItem value="general" title="General Preferences">
Configure your language, timezone, and regional settings here.
</AccordionItem>
<AccordionItem value="security" title="Security & Authentication">
Manage two-factor authentication, active sessions, and password updates.
</AccordionItem>
<AccordionItem value="notifications" title="Notification Channels" disabled>
This section is currently locked by your organization administrator.
</AccordionItem>
<AccordionItem value="billing" title="Billing & Inscriptions">
View your active plan, update payment methods, and download invoices.
</AccordionItem>
</Accordion>
</div>
);
}
Conclusion
Building an accessible UI component requires looking beyond visual aesthetics and addressing how screen readers parse semantic structure and how keyboard operators navigate DOM hierarchies.
By combining strict TypeScript typings, robust ARIA attributes (aria-expanded, aria-controls, aria-labelledby), and comprehensive keyboard event handlers (ArrowDown, ArrowUp, Home, End), our React accordion component delivers an inclusive, bulletproof user experience.