Accordion

PreviousNext

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

Trident UI is ClubMed's open-source component library built on React and Tailwind CSS. It provides accessible, composable primitives for building consistent product interfaces.

Run npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/accordion.json to add the component to your project.

Yes — pass allowMultiple to let any number of panels be open at the same time. By default only one panel is open at once.

'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

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

Usage

'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.

<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.

  • ✈ CDG → DXB — 6 h 30 min — Emirates EK 76
  • ✈ DXB → DPS — 8 h 05 min — Emirates EK 368
  • Total travel time: 17 h 25 min (1 stop)
  • ✈ DPS → SIN — 2 h 35 min — Singapore Airlines SQ 938
  • ✈ SIN → CDG — 13 h 05 min — Singapore Airlines SQ 336
  • Total travel time: 17 h 55 min (1 stop)
  • 🧳 1 × 23 kg checked bag included per passenger
  • 👜 1 × 7 kg carry-on included
  • 🔄 Changes allowed with fee before departure
  • ❌ Non-refundable after ticketing
'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.

Trigger: Header
Wrapped trigger :Tab Item: Header▾

Trident UI is ClubMed's open-source component library built on React and Tailwind CSS. It provides accessible, composable primitives for building consistent product interfaces.

Run npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/accordion.json to add the component to your project.

Yes — pass allowMultiple to let any number of panels be open at the same time.

'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.

<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.

KeyAction
Enter / SpaceToggle the focused panel
TabMove focus to the next focusable element in the page
Shift+TabMove focus to the previous focusable element
ArrowDownMove focus to the next accordion header (wraps around)
ArrowUpMove focus to the previous accordion header (wraps around)
HomeMove focus to the first accordion header
EndMove 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

PropTypeDefaultDescription
itemsAccordionItem[]requiredArray of { header: ReactNode; content: ReactNode } pairs
allowMultiplebooleanfalseWhen true, any number of panels can be open simultaneously; when false, opening a panel closes the previously open one
triggerWrapper(children: ReactNode) => ReactNodeundefinedRender 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
classNamestringundefinedCSS classes applied to the root <div>

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

AccordionItem

FieldTypeDescription
idstringOptional 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
headerReactNodeContent rendered inside the trigger <button>. Can be a string, icon+label combo, or any React element
contentReactNodeContent 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.