Expanding Horizons: Building an Accessible Accordion and Disclosure Component in React
Learn how to build a zero-dependency, fully accessible disclosure and accordion component in React with TypeScript, complete with ARIA attributes, robust keyboard navigation, and smooth height animations.
Expanding Horizons: Building an Accessible Accordion and Disclosure Component in React
When building modern web applications, the disclosure pattern and its multi-item sibling—the accordion—are ubiquitous. From FAQ sections to collapsible sidebar navigation, these components manage space by hiding and revealing content on demand.
However, building an accordion that is truly production-ready involves navigating a labyrinth of accessibility requirements, keyboard interaction models, and tricky CSS height animations. In this post, we will build a zero-dependency, fully accessible disclosure and accordion component from scratch using React and TypeScript.
Anatomy of the Disclosure Pattern
Before writing any code, let’s look at what makes a disclosure accessible. According to the WAI-ARIA Authoring Practices Guide (APG), a disclosure widget typically consists of:
- A button that toggles the visibility of the content. This element requires:
aria-expanded: A boolean (trueorfalse) indicating whether the controlled content is currently visible.aria-controls: An ID reference pointing to the collapsible content container.
- A collapsible content container that holds the revealed information.
An accordion takes this a step further: it is a collection of disclosures where clicking one header typically collapses the others (in a single-expansion model) and requires specific keyboard navigation patterns like arrow key support, Home, and End keys.
Building the Single Disclosure Component
Let’s start by implementing the foundational building block: the single disclosure. We’ll use React hooks and TypeScript to ensure type safety and robust state management.
import React, { useState, useId, useRef } from 'react';
interface DisclosureProps {
title: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
isOpen?: boolean;
onToggle?: (isOpen: boolean) => void;
}
export const Disclosure: React.FC<DisclosureProps> = ({
title,
children,
defaultOpen = false,
isOpen: controlledIsOpen,
onToggle,
}) => {
const [internalIsOpen, setInternalIsOpen] = useState(defaultOpen);
const contentId = useId();
const buttonId = useId();
const isControlled = controlledIsOpen !== undefined;
const isOpen = isControlled ? controlledIsOpen : internalIsOpen;
const handleToggle = () => {
const nextState = !isOpen;
if (!isControlled) {
setInternalIsOpen(nextState);
}
onToggle?.(nextState);
};
return (
<div className="disclosure">
<h3>
<button
id={buttonId}
aria-expanded={isOpen}
aria-controls={contentId}
onClick={handleToggle}
className="disclosure-button"
>
{title}
<span className={`chevron ${isOpen ? 'open' : ''}`} aria-hidden="true">
▼
</span>
</button>
</h3>
<div
id={contentId}
role="region"
aria-labelledby={buttonId}
hidden={!isOpen}
className="disclosure-content"
>
{children}
</div>
</div>
);
};
Why use hidden={!isOpen}?
You might wonder why we rely on the native HTML hidden attribute alongside CSS transitions. The hidden attribute removes the element from the accessibility tree entirely when closed, preventing screen readers from reading hidden text. When opened, we remove hidden so assistive technologies can parse the content correctly.
Scaling Up to an Accordion
An accordion manages multiple disclosure items. It can operate in two modes:
- Single-expansion: Only one panel can be open at a time.
- Multi-expansion: Multiple panels can be open simultaneously.
Let’s implement a robust, accessible Accordion component that supports keyboard navigation across all headers.
import React, { useState, useRef, ReactElement } from 'react';
interface AccordionProps {
children: ReactElement[];
allowMultiple?: boolean;
defaultIndex?: number | number[];
}
export const Accordion: React.FC<AccordionProps> = ({
children,
allowMultiple = false,
defaultIndex = allowMultiple ? [] : -1,
}) => {
const [openIndexes, setOpenIndexes] = useState<number[]>(() => {
if (Array.isArray(defaultIndex)) return defaultIndex;
return defaultIndex >= 0 ? [defaultIndex] : [];
});
const headerRefs = useRef<(HTMLButtonElement | null)[]>([]);
const handleToggle = (index: number) => {
if (allowMultiple) {
setOpenIndexes((prev)
=> prev.includes(index)
? prev.filter((i) => i !== index)
: [...prev, index]
);
} else {
setOpenIndexes((prev) => (prev.includes(index) ? [] : [index]));
}
};
const handleKeyDown = (event: React.KeyboardEvent, index: number) => {
const totalItems = children.length;
let targetIndex: number | null = null;
switch (event.key) {
case 'ArrowDown':
targetIndex = (index + 1) % totalItems;
break;
case 'ArrowUp':
targetIndex = (index - 1 + totalItems) % totalItems;
break;
case 'Home':
targetIndex = 0;
break;
case 'End':
targetIndex = totalItems - 1;
break;
default:
break;
}
if (targetIndex !== null) {
event.preventDefault();
headerRefs.current[targetIndex]?.focus();
}
};
return (
<div className="accordion">
{React.Children.map(children, (child, index) => {
const isOpen = openIndexes.includes(index);
return (
<AccordionItem
{...child.props}
isOpen={isOpen}
onToggle={() => handleToggle(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
buttonRef={(el) => (headerRefs.current[index] = el)}
/>
);
})}
</div>
);
};
Implementing Smooth Height Animations
Animating height from 0 to auto in CSS has historically been challenging because browsers cannot interpolate CSS values to auto.
To achieve a buttery-smooth collapse/expand animation without hardcoding static pixel heights, we can leverage CSS Grid with a clever trick: transitioning the grid-template-rows property from 0fr to 1fr.
The CSS Trick
.accordion-item-content-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 250ms ease-in-out;
}
.accordion-item-content-wrapper.is-open {
grid-template-rows: 1fr;
}
.accordion-item-inner {
overflow: hidden;
}
Integrating with the Component
Let’s apply this wrapper structure to our component so that layout recalculations remain performant and animations run smoothly at 60fps.
interface AccordionItemProps {
title: React.ReactNode;
children: React.ReactNode;
isOpen?: boolean;
onToggle?: () => void;
onKeyDown?: (e: React.KeyboardEvent) => void;
buttonRef?: (node: HTMLButtonElement | null) => void;
}
export const AccordionItem: React.FC<AccordionItemProps> = ({
title,
children,
isOpen = false,
onToggle,
onKeyDown,
buttonRef,
}) => {
const buttonId = useId();
const contentId = useId();
return (
<div className="accordion-item">
<h3>
<button
ref={buttonRef}
id={buttonId}
aria-expanded={isOpen}
aria-controls={contentId}
onClick={onToggle}
onKeyDown={onKeyDown}
className="accordion-header"
>
{title}
<span className={`accordion-icon ${isOpen ? 'rotated' : ''}`} aria-hidden="true">
▼
</span>
</button>
</h3>
<div
className={`accordion-item-content-wrapper ${isOpen ? 'is-open' : ''}`}
role="region"
id={contentId}
aria-labelledby={buttonId}
hidden={!isOpen}
>
<div className="accordion-item-inner">
<div className="accordion-body">
{children}
</div>
</div>
</div>
</div>
);
};
Performance Tip: By animating
grid-template-rows, the browser avoids triggering heavy layout reflows on sibling elements while maintaining fluid visual transitions.
Testing Your Accessible Component
Automated testing catches regressions, but accessibility components require a mixture of unit tests and real-world manual verification.
Unit Testing with React Testing Library
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Accordion } from './Accordion';
import { Disclosure } from './Disclosure';
test('toggles disclosure content on click', async () => {
render(
<Disclosure title="Click me">
<p>Hidden content</p>
</Disclosure>
);
const button = screen.getByRole('button', { name: /click me/i });
const content = screen.getByText(/hidden content/i);
expect(button).toHaveAttribute('aria-expanded', 'false');
expect(content).toBeHidden();
await userEvent.click(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
expect(content).not.toBeHidden();
});
test('supports keyboard navigation across accordion headers', async () => {
render(
<Accordion>
<div title="Section 1">Content 1</div>
<div title="Section 2">Content 2</div>
</Accordion>
);
const firstHeader = screen.getByRole('button', { name: /section 1/i });
const secondHeader = screen.getByRole('button', { name: /section 2/i });
firstHeader.focus();
expect(firstHeader).toHaveFocus();
await userEvent.keyboard('[ArrowDown]');
expect(secondHeader).toHaveFocus();
await userEvent.keyboard('[End]');
expect(secondHeader).toHaveFocus();
});
Manual Verification Checklist
- Screen Reader Test: Turn on VoiceOver (macOS) or NVDA (Windows). Tab to the accordion header. Verify it announces the title, indicates it’s a button, and states whether it is collapsed or expanded.
- Keyboard-Only Test: Unplug your mouse. Navigate exclusively using
Tab,Shift + Tab,Enter,Space,Arrow Keys,Home, andEnd. - Zoom Test: Zoom your browser window to 200%. Verify that text wraps correctly and layout containers do not clip content.
Conclusion
Building accessible React components doesn’t require massive third-party libraries. By leaning on semantic HTML, adhering to WAI-ARIA authoring practices, and utilizing modern CSS grid techniques for animation, you can deliver lightweight, high-performance, and fully inclusive UI components that delight all users.