---
title: Accordion
description: A headless, accessible accordion that stacks panels animated with ElasticHeight. Accepts ReactNode headers and content; supports single-open or multi-open modes.
---

```tsx
'use client';

import { Accordion } from '@/ui/Accordion';

const items = [
  {
    id: 'what-is-trident',
    header: (
      <span className="flex w-full items-center justify-between py-16 text-left font-semibold">
        What is Trident UI?
        <span className="ml-8 text-sm opacity-60 transition-transform in-[[aria-expanded='true']]:rotate-180">
          ▾
        </span>
      </span>
    ),
    content: (
      <p className="pb-16 text-sm text-black/70">
        Trident UI is ClubMed&apos;s open-source component library built on React and Tailwind CSS.
        It provides accessible, composable primitives for building consistent product interfaces.
      </p>
    ),
  },
  {
    id: 'how-to-install',
    header: (
      <span className="flex w-full items-center justify-between py-16 text-left font-semibold">
        How do I install it?
        <span className="ml-8 text-sm opacity-60 transition-transform in-[[aria-expanded='true']]:rotate-180">
          ▾
        </span>
      </span>
    ),
    content: (
      <p className="pb-16 text-sm text-black/70">
        Run{' '}
        <code className="rounded bg-black/5 px-4 py-1 font-mono text-xs">
          npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/accordion.json
        </code>{' '}
        to add the component to your project.
      </p>
    ),
  },
  {
    id: 'allow-multiple',
    header: (
      <span className="flex w-full items-center justify-between py-16 text-left font-semibold">
        Does it support multiple open panels?
        <span className="ml-8 text-sm opacity-60 transition-transform in-[[aria-expanded='true']]:rotate-180">
          ▾
        </span>
      </span>
    ),
    content: (
      <p className="pb-16 text-sm text-black/70">
        Yes — pass{' '}
        <code className="rounded bg-black/5 px-4 py-1 font-mono text-xs">allowMultiple</code> to let
        any number of panels be open at the same time. By default only one panel is open at once.
      </p>
    ),
  },
];

export function AccordionDemo() {
  return (
    <div className="w-full max-w-lg space-y-12">
      <Accordion
        items={items}
        className="divide-y divide-black/10 rounded-12 border border-black/10"
        aria-label="FAQ"
      />
    </div>
  );
}
```

## Installation

Using the CLI:

```bash
npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/accordion.json
```

Or manually, copy the following code into `/ui/Accordion.tsx`:

```tsx
import {
  type ComponentPropsWithoutRef,
  type FunctionComponent,
  type KeyboardEvent,
  type MouseEvent,
  type ReactNode,
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
} from 'react';
import { ElasticHeight } from './ElasticHeight';

export interface AccordionItem {
  id?: string;
  header: ReactNode;
  content: ReactNode;
}

export interface AccordionProps extends ComponentPropsWithoutRef<'div'> {
  items: AccordionItem[];
  /**
   * When true, multiple panels can be open simultaneously.
   * @default false
   */
  allowMultiple?: boolean;
  /**
   * A render function that wraps each item's `header` inside the trigger `<button>`.
   * Receives `item.header` as its argument and must return a ReactNode.
   * Useful for providing a consistent layout (e.g. flex row with a chevron) across all items
   * without repeating the markup in every `AccordionItem.header`.
   *
   * @example
   * triggerWrapper={(children) => (
   *   <span className="flex w-full items-center justify-between py-16 font-semibold">
   *     {children}
   *     <ChevronIcon />
   *   </span>
   * )}
   */
  triggerWrapper?: (children: ReactNode) => ReactNode;
}

export const Accordion: FunctionComponent<AccordionProps> = ({
  items,
  allowMultiple = false,
  triggerWrapper,
  className,
  ...attrs
}) => {
  const uid = useId();
  const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const [openPanels, setOpenPanels] = useState<Set<string>>(() => new Set());

  useEffect(() => {
    if (!allowMultiple) {
      setOpenPanels((prev) => {
        if (prev.size <= 1) return prev;
        const [first] = prev;
        return new Set([first]);
      });
    }
  }, [allowMultiple]);

  const toggle = useCallback(
    (id: string) => {
      setOpenPanels((prev) => {
        const next = new Set(prev);
        if (next.has(id)) {
          next.delete(id);
        } else {
          if (!allowMultiple) {
            next.clear();
          }
          next.add(id);
        }
        return next;
      });
    },
    [allowMultiple],
  );

  const handleClick = useCallback(
    (e: MouseEvent<HTMLButtonElement>) => {
      toggle(e.currentTarget.dataset.itemId!);
    },
    [toggle],
  );

  const handleKeyDown = useCallback(
    (event: KeyboardEvent<HTMLButtonElement>) => {
      const count = items.length;
      const index = Number(event.currentTarget.dataset.index);
      let targetIndex: number | null = null;

      switch (event.code) {
        case 'ArrowDown':
          event.preventDefault();
          targetIndex = (index + 1) % count;
          break;
        case 'ArrowUp':
          event.preventDefault();
          targetIndex = (index - 1 + count) % count;
          break;
        case 'Home':
          event.preventDefault();
          targetIndex = 0;
          break;
        case 'End':
          event.preventDefault();
          targetIndex = count - 1;
          break;
      }

      if (targetIndex !== null) {
        buttonRefs.current[targetIndex]?.focus();
      }
    },
    [items.length],
  );

  const refCallbacks = useRef(new Map<number, (el: HTMLButtonElement | null) => void>());
  const getRefCallback = useCallback((index: number) => {
    if (!refCallbacks.current.has(index)) {
      refCallbacks.current.set(index, (el: HTMLButtonElement | null) => {
        buttonRefs.current[index] = el;
      });
    }
    return refCallbacks.current.get(index)!;
  }, []);

  return (
    <div {...attrs} className={className}>
      {items.map((item, index) => {
        const resolvedId = item.id ?? `${uid}-${index}`;
        const isOpen = openPanels.has(resolvedId);
        const headerId = `accordion-header-${uid}-${index}`;
        const panelId = `accordion-panel-${uid}-${index}`;

        return (
          <div key={resolvedId}>
            <button
              id={headerId}
              ref={getRefCallback(index)}
              type="button"
              aria-expanded={isOpen}
              aria-controls={panelId}
              data-item-id={resolvedId}
              data-index={index}
              onClick={handleClick}
              onKeyDown={handleKeyDown}
              className="outline-none focus-visible:ring-4 focus-visible:ring-black w-full rounded-pill"
            >
              {triggerWrapper ? triggerWrapper(item.header) : item.header}
            </button>
            <ElasticHeight
              id={panelId}
              role="region"
              aria-labelledby={headerId}
              isExpanded={isOpen}
            >
              {item.content}
            </ElasticHeight>
          </div>
        );
      })}
    </div>
  );
};
```

## Usage

```tsx showLineNumbers
'use client';

import { Accordion } from '@/docs/lib/ui/accordion';

const items = [
  {
    header: <span>What is Trident UI?</span>,
    content: <p>Trident UI is ClubMed's open-source React component library.</p>,
  },
  {
    header: <span>How do I install it?</span>,
    content: <p>Run npx shadcn@latest add … to add any component to your project.</p>,
  },
];

export function Component() {
  return <Accordion items={items} aria-label="FAQ" />;
}
```

## Behaviour

### Single-open (default)

By default, opening a panel automatically closes the previously open one. This is the classic
accordion pattern: at most one panel is expanded at any time.

```tsx showLineNumbers
<Accordion items={items} />
```

### Multi-open

Pass `allowMultiple` to let the user open any number of panels independently. Each panel's
collapse / expand state is tracked separately.

```tsx
'use client';

import { Accordion } from '@/ui/Accordion';

const items = [
  {
    header: (
      <span className="flex w-full items-center justify-between py-16 text-left font-semibold">
        Outbound — Paris CDG → Bali DPS
        <span className="ml-8 text-sm opacity-60 transition-transform in-[[aria-expanded='true']]:rotate-180">
          ▾
        </span>
      </span>
    ),
    content: (
      <ul className="space-y-8 pb-16 text-sm text-black/70">
        <li>✈ CDG → DXB — 6 h 30 min — Emirates EK 76</li>
        <li>✈ DXB → DPS — 8 h 05 min — Emirates EK 368</li>
        <li className="font-medium text-black">Total travel time: 17 h 25 min (1 stop)</li>
      </ul>
    ),
  },
  {
    header: (
      <span className="flex w-full items-center justify-between py-16 text-left font-semibold">
        Return — Bali DPS → Paris CDG
        <span className="ml-8 text-sm opacity-60 transition-transform in-[[aria-expanded='true']]:rotate-180">
          ▾
        </span>
      </span>
    ),
    content: (
      <ul className="space-y-8 pb-16 text-sm text-black/70">
        <li>✈ DPS → SIN — 2 h 35 min — Singapore Airlines SQ 938</li>
        <li>✈ SIN → CDG — 13 h 05 min — Singapore Airlines SQ 336</li>
        <li className="font-medium text-black">Total travel time: 17 h 55 min (1 stop)</li>
      </ul>
    ),
  },
  {
    header: (
      <span className="flex w-full items-center justify-between py-16 text-left font-semibold">
        Baggage & fare conditions
        <span className="ml-8 text-sm opacity-60 transition-transform in-[[aria-expanded='true']]:rotate-180">
          ▾
        </span>
      </span>
    ),
    content: (
      <ul className="space-y-8 pb-16 text-sm text-black/70">
        <li>🧳 1 × 23 kg checked bag included per passenger</li>
        <li>👜 1 × 7 kg carry-on included</li>
        <li>🔄 Changes allowed with fee before departure</li>
        <li>❌ Non-refundable after ticketing</li>
      </ul>
    ),
  },
];

export function AccordionMultipleDemo() {
  return (
    <div className="w-full max-w-lg space-y-12">
      <Accordion
        items={items}
        allowMultiple
        className="divide-y divide-black/10 rounded-12 border border-black/10"
        aria-label="Flight details"
      />
    </div>
  );
}
```

### Trigger wrapper

Pass `triggerWrapper` — a function `(children: ReactNode) => ReactNode` — to render a shared
layout around every item's trigger. This lets you declare the flex row, padding, chevron, or any
other chrome in one place instead of repeating it in every `AccordionItem.header`.

```tsx
'use client';

import { type ReactNode } from 'react';

import { Accordion } from '@/ui/Accordion';

const items = [
  {
    id: 'what-is-trident',
    header: 'What is Trident UI?',
    content: (
      <p className="pb-16 text-sm text-black/70">
        Trident UI is ClubMed&apos;s open-source component library built on React and Tailwind CSS.
        It provides accessible, composable primitives for building consistent product interfaces.
      </p>
    ),
  },
  {
    id: 'how-to-install',
    header: 'How do I install it?',
    content: (
      <p className="pb-16 text-sm text-black/70">
        Run{' '}
        <code className="rounded bg-black/5 px-4 py-1 font-mono text-xs">
          npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/accordion.json
        </code>{' '}
        to add the component to your project.
      </p>
    ),
  },
  {
    id: 'allow-multiple',
    header: 'Does it support multiple open panels?',
    content: (
      <p className="pb-16 text-sm text-black/70">
        Yes — pass{' '}
        <code className="rounded bg-black/5 px-4 py-1 font-mono text-xs">allowMultiple</code> to let
        any number of panels be open at the same time.
      </p>
    ),
  },
];

function triggerWrapper(children: ReactNode) {
  return (
    <span className="flex w-full items-center justify-between py-16 text-left font-semibold">
      Tab Item: {children}
      <span className="ml-8 text-sm opacity-60 transition-transform in-[[aria-expanded='true']]:rotate-180">
        ▾
      </span>
    </span>
  );
}

export function AccordionTriggerWrapperDemo() {
  return (
    <div className="w-full max-w-lg space-y-12">
      <div>
        Trigger: <span>Header</span>
      </div>
      <div>Wrapped trigger :{triggerWrapper('Header')}</div>
      <Accordion
        items={items}
        triggerWrapper={triggerWrapper}
        className="divide-y divide-black/10 rounded-12 border border-black/10"
        aria-label="FAQ"
      />
    </div>
  );
}
```

When `triggerWrapper` is omitted, `item.header` renders directly inside the `<button>` — existing
behaviour is unchanged.

### Composing styles

The component carries no visual styling of its own — it is intentionally **naked**. Apply
Tailwind classes (or any CSS) to the `header` / `content` ReactNodes, and use `className`
on the root `<div>` to frame the whole widget.

```tsx showLineNumbers
<Accordion
  items={styledItems}
  className="divide-y divide-black/10 rounded-12 border border-black/10"
  aria-label="FAQ"
/>
```

## Keyboard navigation

The component implements the
[WAI-ARIA Accordion keyboard pattern](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/).

| Key | Action |
| --- | ------ |
| `Enter` / `Space` | Toggle the focused panel |
| `Tab` | Move focus to the next focusable element in the page |
| `Shift+Tab` | Move focus to the previous focusable element |
| `ArrowDown` | Move focus to the next accordion header (wraps around) |
| `ArrowUp` | Move focus to the previous accordion header (wraps around) |
| `Home` | Move focus to the first accordion header |
| `End` | Move focus to the last accordion header |

## Accessibility

- Each trigger is a native `<button type="button">` — keyboard-operable out of the box.
- `aria-expanded` reflects the open/closed state on every button.
- `aria-controls` links each button to its panel by id.
- Each panel carries `role="region"` and `aria-labelledby` so screen readers announce the panel's heading.
- IDs are generated with React's `useId` — unique even when multiple accordions render on the same page.
- Arrow-key navigation moves focus among accordion headers only; interactive content inside a panel is reached with `Tab` as usual.

## API Reference

### Accordion

| Prop | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| `items` | `AccordionItem[]` | **required** | Array of `{ header: ReactNode; content: ReactNode }` pairs |
| `allowMultiple` | `boolean` | `false` | When `true`, any number of panels can be open simultaneously; when `false`, opening a panel closes the previously open one |
| `triggerWrapper` | `(children: ReactNode) => ReactNode` | `undefined` | Render function called with `item.header` for each item. Return value is rendered inside the trigger `<button>` in place of the bare header. Useful for sharing flex layouts, chevrons, or other chrome across all items |
| `className` | `string` | `undefined` | CSS classes applied to the root `<div>` |

Extends all standard HTML `div` attributes (`id`, `aria-label`, `data-*`, etc.).

### AccordionItem

| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | `string` | Optional stable identifier for the item. Used as the open-state key and React `key`. When omitted, an id is auto-generated from the accordion's `useId` token and the item's index |
| `header` | `ReactNode` | Content rendered inside the trigger `<button>`. Can be a string, icon+label combo, or any React element |
| `content` | `ReactNode` | Content revealed when the panel is expanded. Rendered inside an `ElasticHeight` wrapper so height animates smoothly |

## Implementation notes

- Panels are animated via the `ElasticHeight` component (`grid-rows-[0fr]` ↔ `grid-rows-[1fr]`)
  with a 500 ms CSS transition — no JavaScript height calculations or `ResizeObserver`.
- Content stays **in the DOM** even when collapsed, matching the standard accordion pattern
  and ensuring SEO / screen-reader discoverability.
- Open state is fully internal (`useState<Set<number>>`). There is no controlled / `value` prop
  by design — the component is a layout primitive, not a form element.
- Focus management uses a `ref` array so `ArrowUp/Down/Home/End` call `.focus()` imperatively.
- When switching from `allowMultiple=true` to `allowMultiple=false` at runtime, existing open
  panels are **not** auto-closed; the constraint applies on the next user interaction.
