Skip to contentVibraUI
Navigation & layout

Accordion

Stacked sections that open one at a time, or several at once.

Vibra's trigger names what it transitions — its background and colour — where shadcn's transitions all. The panel opens and closes as Base UI's own height transition on --duration-base, where shadcn's runs keyframes at a fixed 0.2s, so it snaps open and shut under reduced motion. The trigger's words and chevron sit on logical sides and a gap apart, where shadcn's sit left and right and can touch, so a right-to-left accordion keeps its chevron at the inline end and a long question never runs into it. Pass multiple to the root to let several sections stay open.

Install

npx shadcn@latest add @vibra/accordion

Needs the @vibra registry in your components.json — set it up once.

Examples

Several open at once

multiple lets any number of sections stay open. An order's details start with two of them.

With icons

Each topic led by its icon. The icon is decoration; the words name the section.

With a summary line

A closed section says what it is set to, so the list reads as a summary until something needs changing.

In a panel

Inside the kit's panel: the items padded from its edge, their rules running the full width.

Separate panels

Each section a frame of its own. The open one rises to the card plane rather than taking an outline.

Nested

Topics that open onto their own questions, each level an accordion of its own.

Disabled, with a reason

A section the plan doesn't include: still in the tab order, and described by why it won't open.

Expand all

Controlled, so one button opens or closes every section and says which it will do next.

Right to left

The demo's questions in Arabic: the text starts at the right and the chevron keeps the inline end.

Props

PropTypeDefaultDescription
multiplebooleanfalseLets any number of sections stay open at once.
value / defaultValue / onValueChangestring[] / string[] / (value: string[]) => void—The open sections, controlled or not — an array even when only one opens at a time.
AccordionItem.valuestring—Names the section in the root's value.
AccordionItem.disabledbooleanfalseThe section won't open. Its trigger stays focusable and dims, so say why beside it.
AccordionTrigger.headingLevel2 | 3 | 4 | 5 | 63The level of the heading each trigger sits in. An accordion straight under a page's h1, with no h2 between, takes 2, so the outline never skips a level.

Dependencies

Source

components/ui/accordion.tsx
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"

function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
  return (
    <AccordionPrimitive.Root
      data-slot="accordion"
      className={cn("flex w-full flex-col", className)}
      {...props}
    />
  )
}

function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
  return (
    <AccordionPrimitive.Item
      data-slot="accordion-item"
      className={cn("not-last:border-b", className)}
      {...props}
    />
  )
}

function AccordionTrigger({
  className,
  children,
  headingLevel,
  ...props
}: AccordionPrimitive.Trigger.Props & {
  /**
   * The level of the heading each trigger sits in — Base UI's h3 unless told.
   * An accordion straight under a page's h1, with no h2 between, takes 2, so
   * the outline never skips a level.
   */
  headingLevel?: 2 | 3 | 4 | 5 | 6
}) {
  const Heading = headingLevel ? (`h${headingLevel}` as const) : undefined
  return (
    <AccordionPrimitive.Header className="flex" render={Heading ? <Heading /> : undefined}>
      <AccordionPrimitive.Trigger
        data-slot="accordion-trigger"
        className={cn(
          "group/accordion-trigger relative flex flex-1 items-start justify-between gap-3 rounded-lg border border-transparent py-2.5 text-start text-sm font-medium transition-[background-color,color] hover:underline focus-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ms-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
          className
        )}
        {...props}
      >
        {children}
        <ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
        <ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
      </AccordionPrimitive.Trigger>
    </AccordionPrimitive.Header>
  )
}

function AccordionContent({
  className,
  children,
  ...props
}: AccordionPrimitive.Panel.Props) {
  return (
    <AccordionPrimitive.Panel
      data-slot="accordion-content"
      // Base UI's own open and close: the panel's height, which Base UI keeps
      // in --accordion-panel-height, transitions from and to zero on the
      // starting and ending styles, over the token — so it stops when the
      // reader asks for less motion. shadcn's copy ran keyframes as well as a
      // transition, which Base UI warns about on every toggle.
      className="h-(--accordion-panel-height) overflow-hidden text-sm transition-[height] duration-(--duration-base) ease-(--ease-standard) data-ending-style:h-0 data-starting-style:h-0"
      {...props}
    >
      <div
        className={cn(
          "pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
          className
        )}
      >
        {children}
      </div>
    </AccordionPrimitive.Panel>
  )
}

export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }