Building an Accessible, Animated React Accordion (Headless, Tested, and Themeable)

Build an accessible, animated React accordion: ARIA semantics, keyboard support, headless API, smooth height transitions, tests, and performance tips.

ASOasis
8 min read
Building an Accessible, Animated React Accordion (Headless, Tested, and Themeable)

Image used for representation purposes only.

Why an Accordion, and What “Good” Looks Like

Accordions reveal and hide related blocks of content on demand. Done well, they:

  • Keep pages scannable while preserving depth
  • Work equally with mouse, touch, and keyboard
  • Announce state changes to assistive tech
  • Animate without jank or layout shifts

In React, the goal is a composable, accessible primitive you can theme, test, and extend. This article builds such an accordion from first principles, then covers animation, performance, and testing.

Accessibility First: Semantics and Keyboard Support

WAI-ARIA Authoring Practices recommend:

  • A heading that contains a button which toggles the panel
  • The button uses aria-expanded and aria-controls
  • The panel is a container with role=“region” and aria-labelledby referencing the button
  • Keyboard: Tab moves between buttons; Space/Enter toggles; optionally Up/Down arrow moves focus between headers; Home/End jump to first/last

Core attributes per item:

  • Button: role implicit (button), aria-expanded, id
  • Panel: role=“region”, id, aria-labelledby, aria-hidden when collapsed

A Minimal, Accessible Accordion (Multiple or Single Open)

Below is a headless, framework-agnostic component set. It supports:

  • Mode: single or multiple
  • Controlled and uncontrolled state
  • Arrow/Home/End navigation (optional but included)
  • Smooth height animation without layout thrash
// Accordion.tsx
import React, {createContext, useContext, useId, useMemo, useRef, useState, useCallback, useEffect} from 'react';

type Mode = 'single' | 'multiple';

type AccordionContextType = {
  mode: Mode;
  value: string[]; // open item values
  setValue: (next: string[]) => void;
  registerHeader: (el: HTMLButtonElement | null) => number; // for roving focus
  focusHeader: (index: number) => void;
  headers: React.MutableRefObject<(HTMLButtonElement | null)[]>;
};

const AccordionCtx = createContext<AccordionContextType | null>(null);

export type AccordionProps = {
  children: React.ReactNode;
  mode?: Mode;
  value?: string[]; // controlled
  defaultValue?: string[]; // uncontrolled
  onValueChange?: (next: string[]) => void;
  className?: string;
};

export function Accordion({
  children,
  mode = 'multiple',
  value,
  defaultValue = [],
  onValueChange,
  className
}: AccordionProps) {
  const isControlled = value !== undefined;
  const [uncontrolled, setUncontrolled] = useState<string[]>(defaultValue);
  const current = isControlled ? (value as string[]) : uncontrolled;

  const headers = useRef<(HTMLButtonElement | null)[]>([]);
  const registerHeader = useCallback((el: HTMLButtonElement | null) => {
    if (!el) return -1;
    const i = headers.current.indexOf(el);
    if (i !== -1) return i;
    headers.current.push(el);
    return headers.current.length - 1;
  }, []);

  const focusHeader = useCallback((index: number) => {
    const el = headers.current[index];
    el?.focus();
  }, []);

  const setValue = useCallback((next: string[]) => {
    if (isControlled) onValueChange?.(next);
    else setUncontrolled(next);
  }, [isControlled, onValueChange]);

  const ctx = useMemo(() => ({ mode, value: current, setValue, registerHeader, focusHeader, headers }), [mode, current, setValue, registerHeader, focusHeader]);

  return (
    <AccordionCtx.Provider value={ctx}>
      <div className={className} data-accordion={mode}>
        {children}
      </div>
    </AccordionCtx.Provider>
  );
}

export type AccordionItemProps = {
  value: string; // unique key per item
  defaultOpen?: boolean;
  children: React.ReactNode;
};

export function AccordionItem({ value, defaultOpen, children }: AccordionItemProps) {
  const ctx = useContext(AccordionCtx)!;
  useEffect(() => {
    if (defaultOpen && !ctx.value.includes(value)) {
      if (ctx.mode === 'single') ctx.setValue([value]);
      else ctx.setValue([...ctx.value, value]);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const open = ctx.value.includes(value);
  return (
    <div data-accordion-item data-state={open ? 'open' : 'closed'} data-value={value}>
      {children}
    </div>
  );
}

export type AccordionHeaderProps = {
  children: React.ReactNode;
  itemValue: string;
};

export function AccordionHeader({ children, itemValue }: AccordionHeaderProps) {
  const ctx = useContext(AccordionCtx)!;
  const buttonRef = useRef<HTMLButtonElement | null>(null);
  const id = useId();
  const buttonId = `acc-h-${id}`;
  const panelId = `acc-p-${id}`;
  const open = ctx.value.includes(itemValue);

  const toggle = () => {
    if (open) ctx.setValue(ctx.value.filter(v => v !== itemValue));
    else ctx.setValue(ctx.mode === 'single' ? [itemValue] : [...ctx.value, itemValue]);
  };

  // Roving focus: Up/Down/Home/End
  const indexRef = useRef<number>(-1);
  useEffect(() => {
    indexRef.current = ctx.registerHeader(buttonRef.current);
  }, [ctx]);

  const onKeyDown = (e: React.KeyboardEvent) => {
    const count = ctx.headers.current.length;
    const i = indexRef.current;
    if (e.key === 'Home') { e.preventDefault(); ctx.focusHeader(0); }
    else if (e.key === 'End') { e.preventDefault(); ctx.focusHeader(count - 1); }
    else if (e.key === 'ArrowDown') { e.preventDefault(); ctx.focusHeader(Math.min(i + 1, count - 1)); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); ctx.focusHeader(Math.max(i - 1, 0)); }
  };

  return (
    <h3 data-accordion-header>
      <button
        ref={buttonRef}
        id={buttonId}
        aria-controls={panelId}
        aria-expanded={open}
        onClick={toggle}
        onKeyDown={onKeyDown}
        type="button"
        className="acc-trigger"
      >
        {children}
        <span aria-hidden className="acc-icon" />
      </button>
    </h3>
  );
}

export type AccordionPanelProps = {
  itemValue: string;
  children: React.ReactNode;
};

export function AccordionPanel({ itemValue, children }: AccordionPanelProps) {
  const ctx = useContext(AccordionCtx)!;
  const open = ctx.value.includes(itemValue);
  const id = useId();
  const buttonId = `acc-h-${id}`; // purely for a11y linkage if used standalone
  const panelId = `acc-p-${id}`;

  // Height animation: measure on open/close
  const ref = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    const el = ref.current!;
    const content = el.firstElementChild as HTMLElement | null;
    if (!content) return;

    const start = el.style.height;
    const end = open ? `${content.scrollHeight}px` : '0px';

    // Prepare
    el.style.willChange = 'height';
    el.style.height = start || (open ? '0px' : `${content.scrollHeight}px`);

    requestAnimationFrame(() => {
      el.style.height = end;
    });

    const handleEnd = () => {
      el.style.willChange = '';
      if (open) {
        el.style.height = 'auto';
      }
    };

    el.addEventListener('transitionend', handleEnd, { once: true });
  }, [open]);

  return (
    <div
      id={panelId}
      role="region"
      aria-labelledby={buttonId}
      aria-hidden={!open}
      data-state={open ? 'open' : 'closed'}
      ref={ref}
      className="acc-panel"
      // inert prevents tabbing into hidden content while keeping it in the DOM for animation
      {...(!open ? { inert: '' as any } : {})}
    >
      <div className="acc-panel-inner">{children}</div>
    </div>
  );
}

Styles for Layout and Animation

The CSS below keeps things unopinionated but polished. It:

  • Uses CSS variables for theming
  • Animates height with transition
  • Rotates an icon on open
/* accordion.css */
:root {
  --acc-radius: 8px;
  --acc-border: 1px solid #e5e7eb;
  --acc-bg: #ffffff;
  --acc-bg-hover: #f9fafb;
  --acc-text: #111827;
  --acc-muted: #6b7280;
  --acc-focus: 2px solid #2563eb;
  --acc-speed: 220ms;
  --acc-ease: cubic-bezier(.2,.7,.3,1);
}

[data-accordion] { display: grid; gap: 8px; }
[data-accordion-item] { border: var(--acc-border); border-radius: var(--acc-radius); background: var(--acc-bg); }

.acc-trigger { width: 100%; text-align: left; padding: 12px 14px; font: inherit; color: var(--acc-text); background: transparent; border: 0; display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 8px; border-radius: inherit; }
.acc-trigger:hover { background: var(--acc-bg-hover); }
.acc-trigger:focus-visible { outline: var(--acc-focus); }

.acc-icon { width: 1rem; height: 1rem; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(-45deg); transition: transform var(--acc-speed) var(--acc-ease); }
[data-state="open"] .acc-icon { transform: rotate(45deg); }

.acc-panel { overflow: hidden; height: 0; transition: height var(--acc-speed) var(--acc-ease); }
.acc-panel-inner { padding: 0 14px 12px 14px; color: var(--acc-muted); }

Usage

import { Accordion, AccordionItem, AccordionHeader, AccordionPanel } from './Accordion';
import './accordion.css';

export function Faq() {
  return (
    <Accordion mode="single" defaultValue={["shipping"]}>
      <AccordionItem value="shipping">
        <AccordionHeader itemValue="shipping">What are your shipping times?</AccordionHeader>
        <AccordionPanel itemValue="shipping">
          <p>Standard shipping takes 35 business days. Expedited options are available at checkout.</p>
        </AccordionPanel>
      </AccordionItem>

      <AccordionItem value="returns">
        <AccordionHeader itemValue="returns">What is the return policy?</AccordionHeader>
        <AccordionPanel itemValue="returns">
          <p>Returns accepted within 30 days in original condition. Start the process from your account page.</p>
        </AccordionPanel>
      </AccordionItem>

      <AccordionItem value="support">
        <AccordionHeader itemValue="support">How can I contact support?</AccordionHeader>
        <AccordionPanel itemValue="support">
          <p>Email support@acme.test or start a chat from the Help Center.</p>
        </AccordionPanel>
      </AccordionItem>
    </Accordion>
  );
}

Controlled vs. Uncontrolled State

  • Uncontrolled: Use defaultValue to set initial open items; internal state owns updates. Simple and resilient.
  • Controlled: Pass value and onValueChange to integrate with forms, URL state, or analytics. Ensure arrays remain stable (e.g., use useMemo when deriving value) to avoid unnecessary re-renders.

Smooth, Accessible Animation: Patterns That Work

  • Height transitions: Measure scrollHeight and transition height; set to auto after expand to allow dynamic content. This article’s AccordionPanel does exactly that.
  • Keep content in DOM to enable animation. Use aria-hidden and inert while collapsed to remove it from the accessibility tree and tab order.
  • Prefer CSS transitions over JS animations for better off-main-thread execution. If content size changes while open, set height back to a pixel value, measure, transition to new size, then return to auto.

Keyboard and Focus Details

  • Space/Enter toggles the current header (inherent to button).
  • ArrowUp/ArrowDown move focus between headers using the registered refs.
  • Home/End jump to first/last header.
  • Always keep “header as button in a heading” for semantic clarity and announcement in screen readers.

Performance Tips for Large Sets

  • Memoize AccordionItem and heavy children to avoid re-rendering all items on each toggle:
    • Extract expensive inner components and wrap with React.memo.
  • Event boundaries: The toggle handler lives on the button only; avoid capturing at higher levels.
  • Virtualize only if panels contain very heavy content and most remain closed; otherwise virtualization can complicate focus and a11y.
  • Defer offscreen work: lazy-load panel content with Suspense or conditional import if data fetching is expensive.

Server Rendering, Hydration, and IDs

  • useId provides stable, collision-free IDs across server and client. Do not concatenate random values at render time—mismatches break aria-controls/aria-labelledby links.
  • If defaultOpen is calculated from URL or cookies, ensure identical logic runs on server and client to prevent hydration warnings.

Theming and Customization

  • Expose data-state and data-value attributes for selectors.
  • Use CSS variables for spacing, radius, colors, and timing.
  • Add motion-reduction support:
@media (prefers-reduced-motion: reduce) {
  .acc-panel { transition: none; }
  .acc-icon { transition: none; }
}

Testing Your Accordion

Use React Testing Library and user-event to validate keyboard and a11y behavior.

// Accordion.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Accordion, AccordionItem, AccordionHeader, AccordionPanel } from './Accordion';

test('toggles with keyboard and updates aria-expanded', async () => {
  const user = userEvent.setup();
  render(
    <Accordion mode="single">
      <AccordionItem value="a">
        <AccordionHeader itemValue="a">A</AccordionHeader>
        <AccordionPanel itemValue="a"><p>A panel</p></AccordionPanel>
      </AccordionItem>
      <AccordionItem value="b">
        <AccordionHeader itemValue="b">B</AccordionHeader>
        <AccordionPanel itemValue="b"><p>B panel</p></AccordionPanel>
      </AccordionItem>
    </Accordion>
  );

  const a = screen.getByRole('button', { name: 'A' });
  const b = screen.getByRole('button', { name: 'B' });

  await user.click(a);
  expect(a).toHaveAttribute('aria-expanded', 'true');

  await user.keyboard('{ArrowDown}');
  expect(b).toHaveFocus();

  await user.keyboard('{Enter}');
  expect(a).toHaveAttribute('aria-expanded', 'false');
  expect(b).toHaveAttribute('aria-expanded', 'true');
});

When to Use a Library

Recreating an accordion is useful for learning, but production teams often prefer robust, audited primitives:

  • Radix UI (Headless): Accessible, unstyled building blocks
  • Headless UI: Tailwind-first, unstyled components
  • React Aria: Hooks providing ARIA-compliant behavior

Choose a headless option if you already have a design system and want consistent theming. Drop-in component libraries are fine for speed, but ensure they meet your a11y and bundle-size requirements.

Common Pitfalls to Avoid

  • Using divs with onClick instead of a button: you lose keyboard and semantic benefits
  • Hiding content with display: none without updating aria attributes: screen readers may misreport state
  • Animating to height: auto without an intermediate measurement: causes no animation or jank
  • Forgetting Home/End and arrow key support when headers are in a vertical list
  • Not preventing focus into collapsed content: use inert and/or aria-hidden plus CSS to disable interaction

Wrap-Up

A well-engineered React accordion is small, accessible, themeable, and testable. Start with correct semantics (button + region), add bulletproof keyboard support, then layer on smooth height animations that respect user preferences. Treat it as a headless primitive and you’ll reuse it across FAQs, filters, settings panes, and dashboards with minimal churn.

Related Posts