Skip to the content

ComponentsPanel

Accordion

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

Import

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

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.

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

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.

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.

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.

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.

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.

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

Keyboard
KeyBehaviour
EnterorSpaceOpens or closes the panel of the focused trigger.
TabMoves 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.
HomeorEndMoves to the first or last trigger.

API

Accordion

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

Accordion props
PropTypeDefaultDescription
typerequired"single" | "multiple"single opens one panel at a time; multiple lets several stay open. Required.
asChildboolean
collapsiblebooleanfalseWhether an accordion item can be collapsed after it has been opened.
defaultValuestring | 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.
disabledbooleanWhether 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"verticalThe layout in which the Accordion operates.
valuestring | 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.

AccordionItem props
PropTypeDefaultDescription
asChildboolean
disabledbooleanWhether or not an accordion is disabled from user interaction.
valuestring | 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.

AccordionTrigger props
PropTypeDefaultDescription
asChildbooleanRender the child element instead, with the trigger's behaviour and classes merged onto it.
indicatorReactNodeReplaces 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.

AccordionContent props
PropTypeDefaultDescription
asChildboolean
forceMounttrueUsed 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.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--cardbackground
--foregroundtext
--muted-foregroundtext
--ringoutline