All posts
25 Sep 2026

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:

  1. The Header (<button>): Must be a native <button> element to ensure built-in focus management and click handling. It requires aria-expanded (indicating open/closed state) and aria-controls (pointing to the ID of the panel it controls).
  2. The Panel (<div>): Must have an id matching the header’s aria-controls, and an aria-labelledby pointing back to the header. It should also utilize role="region" if it contains significant content.
  3. Keyboard Navigation:
    • Enter or Space: 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.

tsx
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 hidden HTML attribute combined with CSS or React’s conditional rendering. Using native hidden (or CSS display: 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.

More posts