Accordion
A vertically stacked set of collapsible sections. Users click a header to expand or collapse its content panel. Built from scratch with React, TypeScript, and Tailwind CSS.
Code
import { SingleRoot } from "../providers/accordion-single";
import { MultipleRoot } from "../providers/accordion-multiple";
import type { AccordionProps, SingleProps, MultipleProps } from "../types";
/**
* Entry point for the accordion compound component.
* Dispatches to SingleRoot or MultipleRoot based on the `type` prop,
* keeping each provider small and focused on a single selection mode.
*/
export function Accordion(props: AccordionProps) {
return props.type === "multiple" ? (
<MultipleRoot {...(props as Omit<MultipleProps, "type">)} />
) : (
<SingleRoot {...(props as Omit<SingleProps, "type">)} />
);
}
Examples
Multiple panels open
Pass type="multiple" to allow any number of items open at the same time. Use defaultValue with an array to pre-open items.
Controlled
Drive the open state from the outside by passing value and onValueChange. The buttons above the accordion below control which item is open.
nullnullnullDisabled item
Pass disabled on any AccordionItem to prevent interaction. The item fades out, ignores clicks, and is skipped during arrow-key navigation. It remains focusable via Tab so keyboard users can still discover it.
Design Decisions
Compound components over a monolithic prop API
The accordion is split into Accordion, AccordionItem, AccordionTrigger, and AccordionContent rather than a single component that accepts a items={[...]} array. This gives consumers full control over markup, order, and composition without the component needing to anticipate every layout variation. Adding a badge to a trigger or inserting a divider between items requires no special prop or escape hatch; you just put it in JSX.
Two contexts, split by update frequency
Most accordion implementations reach for a single context. This one uses two, split so that AccordionTrigger and AccordionContent never re-render from a toggle that does not affect their own item.
AccordionValueContext lives at the root and holds everything the providers own: the volatile selection value, the toggle callback, the containerRef for arrow-key navigation, and the collapsible flag. Only AccordionItem subscribes to it.
AccordionItemContext is provided by each AccordionItem. It contains the computed isOpen boolean, the generated ARIA IDs, the disabled flag, and the stable root values passed through from above. AccordionTrigger and AccordionContent read only from this context. Because AccordionItem wraps its context value in useMemo, the reference only changes when isOpen actually flips for that specific item -- so unaffected trigger and content pairs skip the re-render entirely.
The result: toggling one item causes N item re-renders (every item recomputes isOpen from the root context) plus 2 targeted re-renders (the one trigger and one content whose isOpen changed), regardless of how many items the accordion contains.
useMemo on every context value
Context re-renders all consumers whenever the value reference changes. Wrapping both context objects in useMemo and stabilizing callbacks with useCallback means the reference only changes when the underlying data changes, not on every parent render. This is a low-cost habit that prevents subtle performance cliffs as the component tree grows.
CSS grid-template-rows for animation
The content panel animates open and closed without any JavaScript height measurement. A grid-template-rows transition between 0fr and 1fr lets the browser handle the interpolation, and an overflow-hidden inner wrapper clips content during the transition. The alternative, reading scrollHeight from the DOM, forces a layout, runs in a useEffect, and requires cleanup. The CSS approach has none of those costs.
Uncontrolled by default, controlled when needed
State lives inside the component by default. Passing value and onValueChange opts into controlled mode, useful for URL-synced state, analytics callbacks, or multi-step flows. The internal implementation does not branch on whether props were passed; it always reads from a single source, and the controlled path simply replaces the internal state setter with the external one.
Trade-offs and limitations
Every panel stays in the DOM
The grid-template-rows animation requires the panel to be mounted at all times. A closed item is visually hidden but fully present in the DOM tree. This is the right call for accessibility: aria-controls on the trigger points to the panel's id, and that element must exist for screen readers to follow the relationship. Conditionally rendering with {isOpen && <Panel />} would break that link.
The cost is memory. An accordion with 20 items mounts 20 content trees on page load. If those panels contain charts, images, or data-fetching components, all of them initialize immediately. For a typical FAQ or settings panel this is a non-issue. For an accordion that wraps expensive sub-trees, it's a real constraint and something like lazy initialization or a manual keepMounted flag would be needed.
Animation duration does not scale with panel height
The transition is fixed at 200ms regardless of how tall the panel is. A panel that expands 40px and one that expands 600px both animate in the same time, so the taller one moves roughly 15x faster. For most content this is imperceptible, but for very long panels the snap feels abrupt. The CSS-only approach has no way around this: the browser interpolates 0fr to 1fr linearly and there is no hook to read the resolved height before the transition starts. A scrollHeight-based approach could calculate duration = clamp(150, height * 0.4, 400) and pass it as an inline style, at the cost of a DOM read and a useEffect.
role="group" vs role="region" on panels
AccordionContent defaults to role="group" rather than role="region". Both roles group related content under a label, but role="region" creates a named landmark that screen readers expose as a navigation shortcut. An accordion with 10 items and role="region" on every panel produces 10 landmarks -- enough to flood the landmark list and make it less useful. The WAI-ARIA APG notes this concern explicitly.
role="group" keeps the semantic grouping without creating a landmark, which is the right default for most accordions. Pass role="region" on individual AccordionContent elements when the panel content is significant enough to warrant its own landmark entry and the accordion has fewer than six panels.
The type prop cannot change at runtime
type="single" stores open state as string | null. type="multiple" stores it as Set<string>. These are incompatible shapes. If type changes after the first render, the state does not migrate: the component would need to be remounted with a key prop to reset cleanly. In practice, type is almost always static, so this is rarely a real problem. But it is a meaningful constraint if you ever want to toggle between modes dynamically, for example letting a user choose whether multiple panels can be open at once.