# Accordion

A stack of headings, each showing or hiding its own panel of content.

- **Import:** `import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@booleanpress/ui/accordion"`
- **Radix Accordion:** <https://www.radix-ui.com/primitives/docs/components/accordion>
- **APG Accordion:** <https://www.w3.org/WAI/ARIA/apg/patterns/accordion/>
- **Page:** <https://ui.booleanpress.com/components/accordion> · @booleanpress/ui 0.2.0

## Usage

An accordion groups related sections under headings, so a long page of settings or answers can be scanned. For one region on its own, use a [collapsible](/components/collapsible).

```tsx
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@booleanpress/ui/accordion"

export function DeliveryFaq() {
  return (
    <Accordion type="single" collapsible>
      <AccordionItem value="log">
        <AccordionTrigger>What does the delivery log record?</AccordionTrigger>
        <AccordionContent>Every email the site sends, kept for 30 days.</AccordionContent>
      </AccordionItem>
    </Accordion>
  )
}
```

`type="single"` opens one panel at a time; add `collapsible` so the open one can be closed again. `type="multiple"` lets any number stay open. It is uncontrolled with `defaultValue`, or controlled with `value` and `onValueChange` (a string for `single`, an array for `multiple`). `disabled` on the `Accordion` disables every panel; on an `AccordionItem` it disables that one.

The chevron turns when a panel opens. `indicator` on `AccordionTrigger` replaces it with your own icon; the trigger is the Tailwind group `accordion-trigger`, so an icon can follow the state with `group-data-[state=open]/accordion-trigger:`. The trigger takes any content, such as an icon and a [badge](/components/badge) beside the title. The content's height animates from Radix's measured `--radix-accordion-content-height`.

## Examples

### Basic

One panel open at a time; `collapsible` lets the open one close.

```tsx
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@booleanpress/ui/accordion"

export default function AccordionBasic() {
  return (
    <Accordion type="single" collapsible className="w-full max-w-md">
      <AccordionItem value="log">
        <AccordionTrigger>What does the delivery log record?</AccordionTrigger>
        <AccordionContent>
          Every email the site sends: the recipient, the subject, the status and the mail server&apos;s reply. Entries are
          kept for 30 days.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="resend">
        <AccordionTrigger>Can I resend a failed email?</AccordionTrigger>
        <AccordionContent>
          Yes. Open the entry in the log and choose Resend. The new attempt gets its own entry.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="providers">
        <AccordionTrigger>Which mail providers can I connect?</AccordionTrigger>
        <AccordionContent>
          Any SMTP server, and Amazon SES, Mailgun, Postmark and SendGrid through their APIs.
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  )
}
```

### Multiple

`type="multiple"` keeps several panels open, here with the first open from the start.

```tsx
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@booleanpress/ui/accordion"

export default function AccordionMultiple() {
  return (
    <Accordion type="multiple" defaultValue={["sending"]} className="w-full max-w-md">
      <AccordionItem value="sending">
        <AccordionTrigger>Sending</AccordionTrigger>
        <AccordionContent>
          Mail goes out through the primary connection. When it fails, the backup connection takes over.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="tracking">
        <AccordionTrigger>Tracking</AccordionTrigger>
        <AccordionContent>
          Opens and clicks are counted per message. Tracking pixels are off for transactional email.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="retention">
        <AccordionTrigger>Retention</AccordionTrigger>
        <AccordionContent>
          Log entries older than 30 days are removed every night at 02:00.
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  )
}
```

### Controlled

`value` and `onValueChange` keep the open panel outside, so buttons can open and close panels.

```tsx
import { useState } from "react"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@booleanpress/ui/accordion"
import { Button } from "@booleanpress/ui/button"

const PANELS = [
  { value: "smtp", title: "SMTP connection", body: "Host smtp.example.com, port 587, STARTTLS, signed in as mailer@example.com." },
  { value: "sender", title: "Sender", body: "Mail is sent from Acme Support <support@example.com>, replies go to the help desk." },
  { value: "alerts", title: "Failure alerts", body: "After three failed sends in ten minutes, an alert goes to ops@example.com." },
]

export default function AccordionControlled() {
  const [open, setOpen] = useState("smtp")

  return (
    <div className="flex w-full max-w-md flex-col gap-4">
      <div className="flex flex-wrap justify-center gap-2">
        {PANELS.map((panel, index) => (
          <Button
            key={panel.value}
            variant={open === panel.value ? "default" : "secondary"}
            onClick={() => setOpen(panel.value)}
          >
            Panel {index + 1}
          </Button>
        ))}
        <Button variant="destructive" onClick={() => setOpen("")}>
          Close all
        </Button>
      </div>
      <Accordion type="single" collapsible value={open} onValueChange={setOpen}>
        {PANELS.map((panel) => (
          <AccordionItem key={panel.value} value={panel.value}>
            <AccordionTrigger>{panel.title}</AccordionTrigger>
            <AccordionContent>{panel.body}</AccordionContent>
          </AccordionItem>
        ))}
      </Accordion>
    </div>
  )
}
```

### Custom indicator

`indicator` replaces the chevron with a plus that becomes a minus when the panel opens.

```tsx
import { MinusIcon, PlusIcon } from "lucide-react"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@booleanpress/ui/accordion"

const QUESTIONS = [
  { value: "keys", title: "Where do I find my API key?", body: "Under Settings, then API keys. A key is shown once, when you create it." },
  { value: "rotate", title: "How do I rotate a key?", body: "Create a new key, move your sites to it, then revoke the old one." },
  { value: "scopes", title: "What can a key do?", body: "Each key has scopes: send, read logs, or manage connections." },
]

// The trigger is the Tailwind group `accordion-trigger`: each icon shows in one state.
const indicator = (
  <>
    <PlusIcon className="size-3.5 group-data-[state=open]/accordion-trigger:hidden" />
    <MinusIcon className="hidden size-3.5 group-data-[state=open]/accordion-trigger:block" />
  </>
)

export default function AccordionCustomIndicator() {
  return (
    <Accordion type="single" collapsible className="w-full max-w-md">
      {QUESTIONS.map((question) => (
        <AccordionItem key={question.value} value={question.value}>
          <AccordionTrigger indicator={indicator}>{question.title}</AccordionTrigger>
          <AccordionContent>{question.body}</AccordionContent>
        </AccordionItem>
      ))}
    </Accordion>
  )
}
```

### With content template

An icon and a badge in each header, inside a bordered box.

```tsx
import { CreditCardIcon, LockIcon, MailIcon } from "lucide-react"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@booleanpress/ui/accordion"
import { Badge } from "@booleanpress/ui/badge"

const SECTIONS = [
  { value: "mail", icon: MailIcon, title: "Mail delivery", badge: { label: "Healthy", variant: "success" }, body: "98.6% of 12,480 messages delivered in the last 7 days." },
  { value: "security", icon: LockIcon, title: "Security", badge: { label: "2 warnings", variant: "warning" }, body: "Two API keys have not been used for 90 days. Revoke them if they are no longer needed." },
  { value: "billing", icon: CreditCardIcon, title: "Billing", badge: { label: "Pro", variant: "secondary" }, body: "The Pro plan renews on 1 November 2026." },
] as const

export default function AccordionTemplate() {
  return (
    <Accordion type="single" collapsible className="w-full max-w-md rounded-md border [&>*:last-child]:border-b-0">
      {SECTIONS.map(({ value, icon: Icon, title, badge, body }) => (
        <AccordionItem key={value} value={value}>
          <AccordionTrigger>
            <span className="flex flex-1 items-center gap-2">
              <Icon className="text-muted-foreground" />
              {title}
              <Badge variant={badge.variant} className="ms-auto">
                {badge.label}
              </Badge>
            </span>
          </AccordionTrigger>
          <AccordionContent>{body}</AccordionContent>
        </AccordionItem>
      ))}
    </Accordion>
  )
}
```

### Disabled

`disabled` on the accordion disables every panel; on one item, only that one.

```tsx
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@booleanpress/ui/accordion"

export default function AccordionDisabled() {
  return (
    <div className="flex w-full max-w-md flex-col gap-8">
      <Accordion type="single" collapsible disabled>
        <AccordionItem value="reset">
          <AccordionTrigger>How do I reset my password?</AccordionTrigger>
          <AccordionContent>Choose Forgot password on the sign-in page.</AccordionContent>
        </AccordionItem>
        <AccordionItem value="team">
          <AccordionTrigger>Can I invite my team?</AccordionTrigger>
          <AccordionContent>Yes, from Settings, then Members.</AccordionContent>
        </AccordionItem>
      </Accordion>
      <Accordion type="single" collapsible>
        <AccordionItem value="limit">
          <AccordionTrigger>What happens when I reach the sending limit?</AccordionTrigger>
          <AccordionContent>Mail waits in the queue and goes out when the limit resets at midnight.</AccordionContent>
        </AccordionItem>
        <AccordionItem value="app" disabled>
          <AccordionTrigger>Is there a mobile app?</AccordionTrigger>
          <AccordionContent>Not yet.</AccordionContent>
        </AccordionItem>
      </Accordion>
    </div>
  )
}
```

## Accessibility

**Semantics.** Each header is an `h3` holding a `button` with `aria-expanded` and `aria-controls`; each panel is a `region` named by its trigger (`aria-labelledby`). A disabled trigger has `disabled` and `aria-disabled`; in a `single` accordion that is not `collapsible`, the open trigger is `aria-disabled` too, since it cannot close.

**Labels.** Each trigger is named by its text, and names its panel. Keep the text the same when the panel opens: the state is announced from `aria-expanded`.

**Focus.** Every trigger is a tab stop, in order; the panels' own controls follow their trigger in the tab order. Focus is a 1px `--ring` outline drawn just inside the header.

**Known limits.**

- Closed panels are removed from the page, so find-in-page and screen readers do not see their content.
- The heading level is fixed at `h3`. On a page whose outline needs another level, place the accordion where an `h3` belongs.
- The indicator is decoration (`aria-hidden`); the state is in `aria-expanded`.

### Keyboard

| Key | Behaviour |
| --- | --- |
| Enter or Space | Opens or closes the panel of the focused trigger. |
| Tab | Moves to the next trigger, or into the open panel's controls. |
| ↓ | Moves to the next trigger, wrapping at the end. |
| ↑ | Moves to the previous trigger, wrapping at the start. |
| Home or End | Moves to the first or last trigger. |

## API

### Accordion

Renders Radix Accordion.Root and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `type` (required) | `"single" \| "multiple"` |  | `single` opens one panel at a time; `multiple` lets several stay open. Required. |
| `asChild` | `boolean` |  |  |
| `collapsible` | `boolean` | `false` | Whether an accordion item can be collapsed after it has been opened. |
| `defaultValue` | `string \| string[]` |  | The value of the item whose content is expanded when the accordion is initially rendered. Use `defaultValue` if you do not need to control the state of an accordion. The value of the items whose contents are expanded when the accordion is initially rendered. Use `defaultValue` if you do not need to control the state of an accordion. |
| `dir` | `"ltr" \| "rtl"` |  | The language read direction. |
| `disabled` | `boolean` |  | Whether or not an accordion is disabled from user interaction. |
| `onValueChange` | `((value: string) => void) \| ((value: string[]) => void)` |  | The callback that fires when the state of the accordion changes. |
| `orientation` | `"horizontal" \| "vertical"` | `vertical` | The layout in which the Accordion operates. |
| `value` | `string \| string[]` |  | The controlled stateful value of the accordion item whose content is expanded. The controlled stateful value of the accordion items whose contents are expanded. |

### AccordionItem

Renders Radix Accordion.Item and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |
| `disabled` | `boolean` |  | Whether or not an accordion is disabled from user interaction. |
| `value` | `string \| string[]` |  | The controlled stateful value of the accordion item whose content is expanded. The controlled stateful value of the accordion items whose contents are expanded. |

### AccordionTrigger

Renders Radix Accordion.Trigger and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  | Render the child element instead, with the trigger's behaviour and classes merged onto it. |
| `indicator` | `ReactNode` |  | Replaces the chevron, which turns when the panel opens. The trigger is the Tailwind group `accordion-trigger`. |

### AccordionContent

Renders Radix Accordion.Content and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |
| `forceMount` | `true` |  | Used to force mounting when more control is needed. Useful when controlling animation with React animation libraries. |

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="accordion"` (Accordion), `data-slot="accordion-item"` (AccordionItem), `data-slot="accordion-header"` (AccordionTrigger), `data-slot="accordion-content"` (AccordionContent).

## Theming

Headers and panels sit on `--card`. A closed header is `--muted-foreground`, an open or hovered one `--foreground`; the rule between panels is `--border`; focus is `--ring`. A disabled panel is drawn at 60% opacity.

| Token | Used for |
| --- | --- |
| `--card` | background |
| `--foreground` | text |
| `--muted-foreground` | text |
| `--ring` | outline |
