artui
Components

Accordion

View source

Accessible accordion built on native <details> / <summary>. Adds single-expand coordination, APG keyboard navigation, and focus-into-panel on expand.

npx artui@latest add accordion

Playground

Arrow Down and Arrow Up move focus between headers. Home jumps to the first header, End to the last. Enter and Space toggle the focused panel.

When you expand a panel with Enter, Space, or a click, focus moves into the panel. Screen readers announce the panel name and then read the newly visible content immediately.

The trigger-to-panel relationship, hidden-when-closed behaviour, and Enter/Space toggle are all provided by the browser for free. artui adds the coordination and keyboard patterns on top.

type
headingLevel
disabled item

Usage

Single-expand (uncontrolled)

import { Accordion } from '@/components/accordion';

<Accordion headingLevel={3}>
  <Accordion.Item value="shipping">
    <Accordion.Header>
      <Accordion.Trigger>Shipping and delivery</Accordion.Trigger>
    </Accordion.Header>
    <Accordion.Panel>
      <p>Orders ship within 2 business days.</p>
    </Accordion.Panel>
  </Accordion.Item>
  <Accordion.Item value="returns">
    <Accordion.Header>
      <Accordion.Trigger>Returns and refunds</Accordion.Trigger>
    </Accordion.Header>
    <Accordion.Panel>
      <p>Return any item within 30 days of delivery for a full refund.</p>
    </Accordion.Panel>
  </Accordion.Item>
</Accordion>

Multi-expand

<Accordion type="multiple" headingLevel={3}>
  <Accordion.Item value="keyboard">
    <Accordion.Header>
      <Accordion.Trigger>Keyboard navigation</Accordion.Trigger>
    </Accordion.Header>
    <Accordion.Panel>
      <p>Arrow keys navigate between headers.</p>
    </Accordion.Panel>
  </Accordion.Item>
</Accordion>

Controlled

const [open, setOpen] = useState('');

<Accordion type="single" value={open} onValueChange={setOpen} headingLevel={3}>
  <Accordion.Item value="prerequisites">
    <Accordion.Header>
      <Accordion.Trigger>Prerequisites</Accordion.Trigger>
    </Accordion.Header>
    <Accordion.Panel>
      <p>Node.js 18+ and pnpm 9+ are required.</p>
    </Accordion.Panel>
  </Accordion.Item>
</Accordion>

Programmatic value changes do not move focus. Only user interactions do.

Disabled item

<Accordion headingLevel={3}>
  <Accordion.Item value="billing" disabled>
    <Accordion.Header>
      <Accordion.Trigger>Billing (upgrade required)</Accordion.Trigger>
    </Accordion.Header>
    <Accordion.Panel>
      <p>Available on Pro and Enterprise plans.</p>
    </Accordion.Panel>
  </Accordion.Item>
</Accordion>

API

Accordion (root)

PropTypeDefaultDescription
headingLevelrequired2 | 3 | 4 | 5 | 6noneHeading level rendered by every Accordion.Header. Required: no default, so authors must pick a level that fits the document outline.
type"single" | "multiple""single"Controls whether at most one item can be open ("single") or multiple items can be open simultaneously ("multiple").
valuestring | string[]noneControlled open value. String for single mode, string[] for multiple. Pair with onValueChange.
defaultValuestring | string[]noneUncontrolled initial open value.
onValueChange((value: string) => void) | ((value: string[]) => void)noneCalled when the open state changes from a user interaction. Required when value is provided.
childrenrequiredReactNodenoneOne or more Accordion.Item elements. A dev overlay fires when no Items are rendered.
classNamestringnoneAdditional CSS class applied to the accordion root.

Accordion.Item

PropTypeDefaultDescription
valuerequiredstringnoneStable identifier for this item. Must be unique within the accordion; a dev overlay fires on duplicate values.
disabledbooleannoneMarks the item non-interactive. The summary receives aria-disabled=true and is excluded from Arrow-key navigation.
childrenrequiredReactNodenoneMust contain exactly one Accordion.Header (with Accordion.Trigger inside) and one Accordion.Panel.

Accordion.Header

Renders as <h2> through <h6> inside the <summary>, at the level headingLevel sets on the root.

Accordion.Trigger

Requires exactly one of children, aria-label, or aria-labelledby.

PropTypeDefaultDescription
childrenAccessibleTextnoneVisible label text for the trigger. Exactly one of children, aria-label, or aria-labelledby is required: compile error otherwise.
aria-labelAccessibleTextnoneInvisible label for icon-only triggers. Mutually exclusive with children and aria-labelledby.
aria-labelledbystringnoneID of an external label element. Mutually exclusive with children and aria-label.

Accordion.Panel

PropTypeDefaultDescription
childrenrequiredReactNodenoneContent shown when the item is open.
classNamestringnoneAdditional CSS class applied to the panel element.

Gets role="region" when the accordion has 6 or fewer items, aria-labelledby pointing at the header, and tabIndex={-1} so it can receive focus on expand.

Keyboard

KeyAction
Arrow DownFocus next header; wraps from last to first
Arrow UpFocus previous header; wraps from first to last
HomeFocus the first header
EndFocus the last header
EnterToggle the focused panel
SpaceToggle the focused panel
TabMove focus out of the accordion

On user-initiated expand, focus moves into the panel so screen readers announce the newly visible content. On collapse, focus returns to the header.

Accessibility

CriterionNameHow the component satisfies it
1.3.1(A)Info and RelationshipsBuilt on native <details> / <summary>: the trigger-to-panel relationship is implicit in the DOM and communicated to assistive technology without aria-controls. aria-controls is also set explicitly as a defensive measure for older AT.
1.3.3(A)Sensory CharacteristicsThe expand/collapse indicator is a custom chevron rendered with aria-hidden=true. The UA disclosure marker is suppressed via CSS so it does not create a duplicate or confusing indicator.
2.1.1(A)KeyboardEnter and Space toggle the disclosure natively. ArrowDown/Up/Home/End navigate between summaries without toggling (APG accordion keyboard contract). Disabled items are excluded from Arrow-key navigation.
2.4.3(A)Focus OrderOn a user-initiated expand (click or Enter/Space), focus moves into the panel so screen reader users hear the newly revealed content immediately. Programmatic opens (controlled value changes) do not move focus.
2.4.7(AA)Focus VisibleFocus indicators use outline (not box-shadow) to remain visible in Windows High Contrast Mode.
2.5.5(AAA)Target Size (Enhanced)The summary element has a minimum 44px block-size and is full-width, providing a 44×44 CSS px touch target.
2.4.10(AAA)Section HeadingsEach item's trigger is wrapped in a real heading (h2–h6). headingLevel is required on the root with no default to force authors to choose a level that fits the page outline.
4.1.2(A)Name, Role, ValueThe summary carries aria-expanded (mirrors the open attribute for older AT) and aria-controls. Accessible names on Accordion.Trigger are enforced at compile time via AccessibleNameProps. Dev overlays fire for missing triggers, missing panels, duplicate values, and empty accordions.

Do

Set headingLevel to match your page outline

<h2>Frequently asked questions</h2>
<Accordion headingLevel={3}>
  <Accordion.Item value="what-is-artui">
    <Accordion.Header>
      <Accordion.Trigger>What is artui?</Accordion.Trigger>
    </Accordion.Header>
    <Accordion.Panel>
      <p>An accessibility-first React component library.</p>
    </Accordion.Panel>
  </Accordion.Item>
</Accordion>

Provide a meaningful accessible name on every Trigger

<Accordion.Trigger>Shipping and delivery policy</Accordion.Trigger>

The name describes the panel content at a glance.

Use disabled to signal unavailable sections

<Accordion.Item value="billing" disabled>
  <Accordion.Header>
    <Accordion.Trigger>Billing (upgrade required)</Accordion.Trigger>
  </Accordion.Header>
  <Accordion.Panel>
    <p>Available on Pro and Enterprise plans.</p>
  </Accordion.Panel>
</Accordion.Item>

Visible disabled items communicate that a section exists but is gated.

Don't

Wrap Accordion.Header in your own heading element

<h2>
  <Accordion.Header>
    <Accordion.Trigger>Section title</Accordion.Trigger>
  </Accordion.Header>
</h2>

Accordion.Header already renders an <hN> element. Set headingLevel on the root instead.

Add aria-expanded to the Trigger manually

<Accordion.Trigger aria-expanded={isOpen}>Section title</Accordion.Trigger>

aria-expanded is set on the <summary> by Accordion.Item. Setting it on the Trigger creates a duplicate, conflicting ARIA attribute.

Reuse the same value across items

<Accordion.Item value="settings">...</Accordion.Item>
<Accordion.Item value="settings">...</Accordion.Item>

Duplicate values break open-state tracking and trigger a dev overlay.

On this page