# Accordion

> Show related sections that people can expand and collapse as needed.

Source: https://www.honestui.com/docs/components/accordion

```tsx
import {
  Accordion,
  AccordionItem,
  AccordionPanel,
  AccordionTrigger,
} from "@/components/honest-ui/ui/accordion"

const items = [
  {
    id: "1",
    title: "What is Base UI?",
    content:
      "Base UI is a library of high-quality unstyled React components for design systems and web apps.",
  },
  {
    id: "2",
    title: "How do I get started?",
    content:
      "Head to the \"Quick start\" guide in the docs. If you've used unstyled libraries before, you'll feel at home.",
  },
  {
    id: "3",
    title: "Can I use it for my project?",
    content: "Of course! Base UI is free and open source.",
  },
]

export function AccordionDemo() {
  return (
    <Accordion className="w-full max-w-lg" defaultValue={["3"]}>
      {items.map((item) => (
        <AccordionItem value={item.id} key={item.id}>
          <AccordionTrigger>{item.title}</AccordionTrigger>
          <AccordionPanel>{item.content}</AccordionPanel>
        </AccordionItem>
      ))}
    </Accordion>
  )
}

```

## Overview [#overview]

Use an accordion when the page has several peer sections that people scan by heading first and read selectively: FAQs, settings groups, filter facets, detail panels, and optional sections of long forms. Opening a panel keeps its neighbors visible, so the accordion works best when the headings alone tell people whether a section matters to them.

Avoid an accordion when people must compare contents side by side, such as pricing plans or spec sheets, and never put required steps or the only explanation of an error inside a closed panel. If everything should stay visible, use plain headings and spacing instead. See [Don't do this](#dont-do-this) for the failure modes.

## Anatomy [#anatomy]

An accordion is made from a root, one or more items, a header-wrapped trigger for each item, and a panel for each item. The trigger names the section as a question or noun phrase; the built-in chevron rotates when the panel opens. The panel content stays short enough that opening one item does not push the rest of the page out of view.

Each trigger is a real button with `aria-expanded`, so assistive technology announces both the section name and its open state. The panel is connected to its trigger through ids managed by the component.

## Behavior [#behavior]

**Single versus multiple.** Pass `type="single"` when only one panel should be open at a time, and `type="multiple"` when several panels can stay open. In single mode the Honest UI wrapper accepts plain strings for `value`, `defaultValue`, and `onValueChange`, so you never handle arrays by hand.

**Collapsible.** By default a single accordion keeps one panel open: collapsing the last open panel is cancelled. Pass `collapsible` when all panels may be closed at once. Multiple accordions are always collapsible.

**Controlled state.** Use `value` and `onValueChange` when the open panels need to sync with routing, saved preferences, or another part of the page. See the [controlled example](#controlled-accordion).

**Disabled items.** A disabled item's trigger stays reachable by keyboard focus but cannot be activated, and it is dimmed. Explain nearby why the section is locked when the reason is not obvious.

## Accessibility [#accessibility]

Every trigger is a real `<button>` and remains in the <kbd>Tab</kbd> order, so you can reach any section with repeated <kbd>Tab</kbd> presses. Arrow keys provide faster movement once a trigger has focus:

* <kbd>ArrowDown</kbd> and <kbd>ArrowUp</kbd> move between triggers in a vertical accordion, the default orientation.
* <kbd>ArrowLeft</kbd> and <kbd>ArrowRight</kbd> move between triggers in a horizontal accordion. In right-to-left layouts these directions mirror automatically.
* <kbd>Home</kbd> and <kbd>End</kbd> jump to the first and last enabled trigger. Movement wraps around by default; set `loopFocus={false}` on the root to stop at the ends.

<kbd>Enter</kbd> and <kbd>Space</kbd> toggle the focused panel. The trigger carries `aria-expanded` at all times, and `aria-controls` references the panel while it is open. Disabled triggers stay focusable so screen readers can still find and announce them.

Panel colors use theme tokens, so open and closed states adapt to dark mode automatically. Triggers wrap their label rather than truncating it, which keeps long localized questions readable; the chevron reserves its own space and does not collide with text.

The accordion exposes no loading or error states. Render progress or failure feedback inside the panel content itself.

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;accordion&#x22;]" />
  

  
    
      
        Install the following dependencies:
      

      ```bash
      npm install @base-ui-components/react
      ```

      
        Copy and paste the following code into your project.
      

      ### components/ui/accordion.tsx

```tsx
"use client"

import { Accordion as AccordionPrimitive } from "@base-ui-components/react/accordion"
import { ChevronDown as ChevronDownIcon } from "honestui/icons"

import { cn } from "@/lib/utils"

type AccordionValue = NonNullable<AccordionPrimitive.Root.Props["value"]>
type AccordionChangeDetails = AccordionPrimitive.Root.ChangeEventDetails

type AccordionNativeProps = AccordionPrimitive.Root.Props & {
  collapsible?: boolean
  type?: undefined
}

type AccordionSingleProps = Omit<
  AccordionPrimitive.Root.Props,
  "defaultValue" | "multiple" | "onValueChange" | "value"
> & {
  type: "single"
  collapsible?: boolean
  defaultValue?: string
  multiple?: false
  onValueChange?: (value: string, eventDetails: AccordionChangeDetails) => void
  value?: string
}

type AccordionMultipleProps = Omit<
  AccordionPrimitive.Root.Props,
  "defaultValue" | "multiple" | "onValueChange" | "value"
> & {
  type: "multiple"
  collapsible?: boolean
  defaultValue?: AccordionValue
  multiple?: true
  onValueChange?: AccordionPrimitive.Root.Props["onValueChange"]
  value?: AccordionValue
}

type AccordionProps =
  | AccordionNativeProps
  | AccordionSingleProps
  | AccordionMultipleProps

function Accordion({
  collapsible,
  defaultValue,
  multiple,
  onValueChange,
  type,
  value,
  ...props
}: AccordionProps) {
  const isSingle = type === "single"
  const resolvedMultiple = type ? type === "multiple" : multiple
  const normalizedDefaultValue: AccordionValue | undefined =
    isSingle && typeof defaultValue === "string"
      ? [defaultValue]
      : Array.isArray(defaultValue)
        ? defaultValue
        : undefined
  const normalizedValue: AccordionValue | undefined =
    isSingle && typeof value === "string"
      ? [value]
      : Array.isArray(value)
        ? value
        : undefined

  return (
    <AccordionPrimitive.Root
      data-slot="accordion"
      defaultValue={normalizedDefaultValue}
      multiple={resolvedMultiple}
      onValueChange={(nextValue, eventDetails) => {
        if (isSingle) {
          if (!collapsible && nextValue.length === 0) {
            eventDetails.cancel()
            return
          }

          onValueChange?.(nextValue[0] ?? "", eventDetails)
        } else {
          onValueChange?.(nextValue, eventDetails)
        }
      }}
      value={normalizedValue}
      {...props}
    />
  )
}

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

function AccordionTrigger({
  className,
  children,
  ...props
}: AccordionPrimitive.Trigger.Props) {
  return (
    <AccordionPrimitive.Header className="flex">
      <AccordionPrimitive.Trigger
        data-slot="accordion-trigger"
        className={cn(
          "flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none focus-visible:ring-[3px] focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-64 [&[data-panel-open]>svg]:rotate-180",
          className
        )}
        {...props}
      >
        {children}
        <ChevronDownIcon className="pointer-events-none size-4 shrink-0 translate-y-0.5 opacity-72 transition-transform duration-200 ease-in-out" />
      </AccordionPrimitive.Trigger>
    </AccordionPrimitive.Header>
  )
}

function AccordionPanel({
  className,
  children,
  ...props
}: AccordionPrimitive.Panel.Props) {
  return (
    <AccordionPrimitive.Panel
      data-slot="accordion-panel"
      className="h-(--accordion-panel-height) overflow-hidden text-sm text-muted-foreground transition-[height] duration-200 ease-in-out data-ending-style:h-0 data-starting-style:h-0"
      {...props}
    >
      <div className={cn("pt-0 pb-4", className)}>{children}</div>
    </AccordionPrimitive.Panel>
  )
}

export {
  Accordion,
  AccordionItem,
  AccordionTrigger,
  AccordionPanel,
  AccordionPanel as AccordionContent,
}

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import {
  Accordion,
  AccordionItem,
  AccordionPanel,
  AccordionTrigger,
} from "@/components/ui/accordion";
```

```tsx
<Accordion>
  <AccordionItem value="item-1">
    <AccordionTrigger>Is it accessible?</AccordionTrigger>
    <AccordionPanel>
      Yes. It adheres to the WAI-ARIA design pattern.
    </AccordionPanel>
  </AccordionItem>
</Accordion>
```

Set `defaultValue={["item-1"]}` to open a panel initially, and `type="multiple"` when several panels can stay open. `AccordionContent` is an alias of `AccordionPanel`, so either name works.

## Don't do this [#dont-do-this]

### Burying required content in a collapsed panel [#burying-required-content-in-a-collapsed-panel]

```tsx
// Bad
<Accordion>
  <AccordionItem value="error">
    <AccordionTrigger>Why did my payment fail?</AccordionTrigger>
    <AccordionPanel>The card was declined. Retry or add another card.</AccordionPanel>
  </AccordionItem>
</Accordion>
```

```tsx
// Good
<div role="alert">
  <p>The card was declined. Retry or add another card.</p>
</div>
```

People should not have to discover and expand a section to learn something they are required to know. Anything the task depends on — required fields, error recovery, legal obligations — belongs in the open page flow, where it cannot be missed.

### Comparing content across closed panels [#comparing-content-across-closed-panels]

```tsx
// Bad
<Accordion type="multiple">
  <AccordionItem value="starter"><AccordionTrigger>Starter</AccordionTrigger><AccordionPanel>$9, 3 seats</AccordionPanel></AccordionItem>
  <AccordionItem value="team"><AccordionTrigger>Team</AccordionTrigger><AccordionPanel>$29, 10 seats</AccordionPanel></AccordionItem>
</Accordion>
```

```tsx
// Good
<table>
  {/* Plans as columns, features as rows */}
</table>
```

An accordion forces people to hold one panel's contents in memory while opening the next, because only expanded content is visible. Decisions that require comparison — plans, tiers, specs — need everything visible at once: a table, columns, or plain stacked sections.

### Vague trigger labels [#vague-trigger-labels]

```tsx
// Bad
<AccordionTrigger>More info</AccordionTrigger>
```

```tsx
// Good
<AccordionTrigger>How do I export my data?</AccordionTrigger>
```

The trigger is the only thing visible before expansion, so it carries the entire scanning job. Labels like `More info` or `Details` tell screen-reader users navigating by form controls nothing about what hides underneath. Write each trigger as the question it answers or the noun it expands.

## Examples [#examples]

### Single Accordion [#single-accordion]

Opening one section closes the previous one. Add `collapsible` if the person should be able to close every section.

```tsx
import {
  Accordion,
  AccordionItem,
  AccordionPanel,
  AccordionTrigger,
} from "@/components/honest-ui/ui/accordion"

export function AccordionSingleDemo() {
  return (
    <Accordion className="w-full max-w-lg" multiple={false}>
      <AccordionItem value="item-1">
        <AccordionTrigger>What is Base UI?</AccordionTrigger>
        <AccordionPanel>
          Honest UI gives you thoughtful components with visible, editable
          code that stays in your project.
        </AccordionPanel>
      </AccordionItem>
      <AccordionItem value="item-2">
        <AccordionTrigger>How do I get started?</AccordionTrigger>
        <AccordionPanel>
          Head to the “Get started” guide in the docs. If you’ve used
          component libraries before, you’ll feel at home.
        </AccordionPanel>
      </AccordionItem>
      <AccordionItem value="item-3">
        <AccordionTrigger>
          Can I use it for my project?
        </AccordionTrigger>
        <AccordionPanel>
          Yes. Honest UI is free and open source.
        </AccordionPanel>
      </AccordionItem>
    </Accordion>
  )
}

```

### Multiple Accordion [#multiple-accordion]

Each section toggles independently, so answers can stay open side by side.

```tsx
import {
  Accordion,
  AccordionItem,
  AccordionPanel,
  AccordionTrigger,
} from "@/components/honest-ui/ui/accordion"

export function AccordionMultipleDemo() {
  return (
    <Accordion className="w-full max-w-lg" multiple={true}>
      <AccordionItem value="item-1">
        <AccordionTrigger>What is Base UI?</AccordionTrigger>
        <AccordionPanel>
          Honest UI gives you thoughtful components with visible, editable
          code that stays in your project.
        </AccordionPanel>
      </AccordionItem>
      <AccordionItem value="item-2">
        <AccordionTrigger>How do I get started?</AccordionTrigger>
        <AccordionPanel>
          Head to the “Get started” guide in the docs. If you’ve used
          component libraries before, you’ll feel at home.
        </AccordionPanel>
      </AccordionItem>
      <AccordionItem value="item-3">
        <AccordionTrigger>
          Can I use it for my project?
        </AccordionTrigger>
        <AccordionPanel>
          Yes. Honest UI is free and open source.
        </AccordionPanel>
      </AccordionItem>
    </Accordion>
  )
}

```

### Controlled Accordion [#controlled-accordion]

Application state owns the open panels here, which is how you sync an accordion to a route query or a saved preference.

```tsx
"use client"

import * as React from "react"

import {
  Accordion,
  AccordionItem,
  AccordionPanel,
  AccordionTrigger,
} from "@/components/honest-ui/ui/accordion"
import { Button } from "@/components/honest-ui/ui/button"

export function AccordionControlledDemo() {
  const [value, setValue] = React.useState<string[]>([])

  return (
    <div className="flex w-full max-w-lg flex-col gap-4">
      <Accordion className="w-full" value={value} onValueChange={setValue}>
        <AccordionItem value="item-1">
          <AccordionTrigger>What is Base UI?</AccordionTrigger>
          <AccordionPanel>
            Honest UI gives you thoughtful components with visible, editable
            code that stays in your project.
          </AccordionPanel>
        </AccordionItem>
        <AccordionItem value="item-2">
          <AccordionTrigger>How do I get started?</AccordionTrigger>
          <AccordionPanel>
            Head to the “Get started” guide in the docs. If you’ve used
            component libraries before, you’ll feel at home.
          </AccordionPanel>
        </AccordionItem>
        <AccordionItem value="item-3">
          <AccordionTrigger>
            Can I use it for my project?
          </AccordionTrigger>
          <AccordionPanel>
            Yes. Honest UI is free and open source.
          </AccordionPanel>
        </AccordionItem>
      </Accordion>

      <div className="flex flex-col items-start gap-4">
        <Button
          variant="secondary"
          onClick={() => setValue(["item-1", "item-2"])}
        >
          Open First Two
        </Button>
        <p className="text-sm text-muted-foreground">
          Open items: {value.length > 0 ? value.join(", ") : "None"}
        </p>
      </div>
    </div>
  )
}

```

### Disabled items [#disabled-items]

The locked trigger stays visible and focusable but dimmed, so people can see that premium content exists even though it cannot be opened.

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

const items = [
  {
    value: "item-1",
    trigger: "Can I access my account history?",
    content:
      "Yes, you can view your complete account history including all transactions, plan changes, and support tickets in the Account History section of your dashboard.",
    disabled: false,
  },
  {
    value: "item-2",
    trigger: "Premium feature information (Locked)",
    content:
      "This section contains information about premium features. Upgrade your plan to access this content.",
    disabled: true,
  },
  {
    value: "item-3",
    trigger: "How do I update my email address?",
    content:
      "You can update your email address in your account settings. You'll receive a verification email at your new address to confirm the change.",
    disabled: false,
  },
]

export function AccordionDisabled() {
  return (
    <div className="mx-auto mb-auto w-full max-w-lg">
      <Accordion
        multiple={false}
        defaultValue={["item-1"]}
        className="overflow-hidden rounded-lg border border-border"
      >
        {items.map((item) => (
          <AccordionItem
            key={item.value}
            value={item.value}
            disabled={item.disabled}
            className="data-open:bg-muted/50"
          >
            <AccordionTrigger className="px-4 py-4 hover:no-underline">
              {item.trigger}
            </AccordionTrigger>
            <AccordionContent className="px-4 pt-0 pb-4">
              {item.content}
            </AccordionContent>
          </AccordionItem>
        ))}
      </Accordion>
    </div>
  )
}

```

### Inside a Card [#inside-a-card]

An accordion pairs well with a card header that frames the topic and a description that sets expectations.

```tsx
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/honest-ui/ui/accordion";
import { Button } from "@/components/honest-ui/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/honest-ui/ui/card";
import { ArrowUpRight as ArrowUpRightIcon } from "honestui/icons";

const items = [
  {
    value: "installation",
    trigger: "How do I add a component?",
    content: (
      <>
        <p>
          Start with the installation guide, then add only the components your
          project uses. The copied source stays in your repository.
        </p>
        <Button
          render={<Link href="/docs/get-started" />}
          size="sm"
          className="mt-4"
        >
          Read installation guide
          <ArrowUpRightIcon className="size-4" />
        </Button>
      </>
    ),
  },
  {
    value: "ownership",
    trigger: "Where does the code live?",
    content: (
      <>
        <p>
          Component source is copied into your project. You can inspect, edit,
          test, and remove it without relying on a hosted runtime.
        </p>
      </>
    ),
  },
  {
    value: "security",
    trigger: "Does Honest UI secure my application?",
    content: (
      <>
        <p>
          Honest UI components are source code, so your application keeps
          responsibility for authentication, authorization, storage, and data
          handling.
        </p>
        <p>
          Review dependencies and application behavior against your own threat
          model before shipping.
        </p>
      </>
    ),
  },
];

export function AccordionInCard() {
  return (
    <div className="mx-auto mb-auto w-full max-w-lg">
      <Card>
        <CardHeader>
          <CardTitle>Honest UI basics</CardTitle>
          <CardDescription>
            Common questions about installing and owning the component source
          </CardDescription>
        </CardHeader>
        <CardContent>
          <Accordion multiple defaultValue={["installation"]}>
            {items.map((item) => (
              <AccordionItem key={item.value} value={item.value}>
                <AccordionTrigger>{item.trigger}</AccordionTrigger>
                <AccordionContent>{item.content}</AccordionContent>
              </AccordionItem>
            ))}
          </Accordion>
        </CardContent>
      </Card>
    </div>
  );
}
import Link from "next/link";

```

### Leading icon [#leading-icon]

Swap the trailing chevron for a leading indicator that rotates from right to down as panels open.

```tsx
import { ChevronRight as ChevronRightIcon } from "honestui/icons"

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/honest-ui/ui/accordion"

const items = [
  {
    value: "item-1",
    trigger: "Can I use this for my project?",
    content:
      "Yes, you can use Honest UI for any of your personal or commercial projects. The library is distributed under the MIT license.",
  },
  {
    value: "item-2",
    trigger: "Is there a Figma file available?",
    content:
      "We are currently working on a comprehensive Figma design system that will be released soon to all Honest UI users.",
  },
  {
    value: "item-3",
    trigger: "How do I contribute to Honest UI?",
    content:
      "You can contribute by reporting bugs, suggesting features, or submitting pull requests on our GitHub repository.",
  },
]

export function AccordionLeadingIcon() {
  return (
    <div className="mx-auto mb-auto w-full max-w-lg">
      <Accordion multiple={false} defaultValue={["item-1"]}>
        {items.map((item) => (
          <AccordionItem key={item.value} value={item.value}>
            <AccordionTrigger className="flex-row-reverse items-center justify-end gap-3 py-3 hover:no-underline data-[panel-open]:[&>svg:first-of-type]:rotate-90 [&>svg:last-child]:hidden">
              <span className="font-medium text-foreground/90">
                {item.trigger}
              </span>
              <ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform duration-200" />
            </AccordionTrigger>
            <AccordionContent className="ps-7 leading-relaxed text-muted-foreground">
              {item.content}
            </AccordionContent>
          </AccordionItem>
        ))}
      </Accordion>
    </div>
  )
}

```

### Product FAQ [#product-faq]

Short question-and-answer pairs where the headings do the scanning work.

```tsx
import {
  Accordion,
  AccordionItem,
  AccordionPanel,
  AccordionTrigger,
} from "@/components/honest-ui/ui/accordion"

const questions = [
  ["Plan limits", "Seats, projects, and storage can be adjusted from billing settings."],
  ["Exports", "Workspace admins can export reports as CSV at any time."],
  ["Support", "Priority support is included on every team plan."],
]

export function AccordionProductFaq() {
  return (
    <Accordion className="w-full max-w-md" defaultValue={["exports"]}>
      {questions.map(([title, content]) => (
        <AccordionItem key={title} value={title.toLowerCase().replaceAll(" ", "-")}>
          <AccordionTrigger>{title}</AccordionTrigger>
          <AccordionPanel>{content}</AccordionPanel>
        </AccordionItem>
      ))}
    </Accordion>
  )
}

```

## API reference [#api-reference]

The Honest UI wrapper normalizes single and multiple selection on top of Base UI's Accordion Root. Parts forward their matching Base UI props.

| Prop            | Values                                  | Default  |
| --------------- | --------------------------------------- | -------- |
| `type`          | `"single"`, `"multiple"`                | multiple |
| `collapsible`   | boolean                                 | `false`  |
| `value`         | string (single) or string\[] (multiple) | —        |
| `defaultValue`  | string (single) or string\[] (multiple) | —        |
| `onValueChange` | `(value, eventDetails) => void`         | —        |

With `type="single"`, `value`, `defaultValue`, and the `onValueChange` argument are plain strings, and collapsing the last open panel is cancelled unless `collapsible` is set. With `type="multiple"`, values are arrays and panels toggle freely. Base UI props such as `disabled`, `orientation`, `loopFocus`, `keepMounted`, and `hiddenUntilFound` pass through to the root; `hiddenUntilFound` lets browser page search expand a closed panel when it finds matching text inside.

`AccordionItem` accepts `disabled` and `open` state attributes. `AccordionContent` is an alias of `AccordionPanel`.

See the [Base UI Accordion API](https://base-ui.com/react/components/accordion#api-reference).
