Skip to content

Accordion

A stack of headings that each reveal a section of content. Progressive disclosure for secondary information.

SourceBase UIRegistry

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

export default function AccordionDemo() {
  return (
    <Accordion defaultValue={["shipping"]} className="w-full max-w-sm">
      <AccordionItem value="shipping">
        <AccordionTrigger>How long does shipping take?</AccordionTrigger>
        <AccordionContent>
          Orders ship within one business day. Delivery takes 2–4 days in the EU
          and 5–8 days everywhere else.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="returns">
        <AccordionTrigger>Can I return an item?</AccordionTrigger>
        <AccordionContent>
          Yes — within 30 days, in its original condition. Returns are free for
          members.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="warranty">
        <AccordionTrigger>Is there a warranty?</AccordionTrigger>
        <AccordionContent>
          Every product carries a two-year warranty against manufacturing
          defects.
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  )
}

Anatomy

  1. 1TriggerA button inside a heading that names the section. The whole row toggles it.
  2. 2ItemOne section: a trigger and its panel. Hairlines separate the items.
  3. 3IconPoints down while the section is closed and turns over when it opens.
  4. 4PanelThe section's content. It animates its height open and closed, and stays out of the tab order while closed.

Installation

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/accordion.json

Usage

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"
<Accordion defaultValue={["shipping"]}>
  <AccordionItem value="shipping">
    <AccordionTrigger>How long does shipping take?</AccordionTrigger>
    <AccordionContent>Orders ship within one business day.</AccordionContent>
  </AccordionItem>
</Accordion>

defaultValue and value are always arrays of item values — even when only one item can be open. Give every item a stable value so its state survives re-renders and can be controlled.

Examples

FAQ

The classic use: many questions, few of them relevant to any one reader. One item opens at a time by default, so the list stays short and scannable as people move between answers.

Frequently asked questions

Everything about plans, billing and your data.

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

const questions = [
  {
    value: "seats",
    question: "How are seats billed?",
    answer:
      "You pay for active members only. Someone who hasn't signed in during a billing period isn't charged, and seats are prorated to the day when you add people mid-cycle.",
  },
  {
    value: "switch",
    question: "Can I switch plans later?",
    answer:
      "Any time. Upgrades take effect immediately; downgrades apply at the end of the current billing period so you never lose access to features you paid for.",
  },
  {
    value: "trial",
    question: "What happens when my trial ends?",
    answer:
      "Your workspace moves to the Free plan. Nothing is deleted — projects above the Free limits become read-only until you upgrade or archive them.",
  },
  {
    value: "invoices",
    question: "Do you provide invoices with VAT?",
    answer:
      "Yes. Add your company details and VAT ID under Billing and every invoice will include them. Invoices are available as PDF and can be sent to a separate billing email.",
  },
  {
    value: "security",
    question: "Where is my data stored?",
    answer:
      "In the EU by default, encrypted at rest and in transit. Enterprise workspaces can choose a data region and bring their own encryption keys.",
  },
]

export default function AccordionFaq() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-6">
      <div className="flex flex-col gap-1.5">
        <h3 className="text-heading-md text-foreground">
          Frequently asked questions
        </h3>
        <p className="text-sm text-muted-foreground">
          Everything about plans, billing and your data.
        </p>
      </div>
      <Accordion>
        {questions.map((item) => (
          <AccordionItem key={item.value} value={item.value}>
            <AccordionTrigger>{item.question}</AccordionTrigger>
            <AccordionContent>{item.answer}</AccordionContent>
          </AccordionItem>
        ))}
      </Accordion>
    </div>
  )
}

Multiple open

Set multiple when people need to compare or work across sections at the same time — filters, settings, grouped form options. Here two filter groups start open.

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"

const filters = [
  {
    value: "status",
    label: "Status",
    options: ["Backlog", "In progress", "In review", "Done"],
  },
  {
    value: "priority",
    label: "Priority",
    options: ["Urgent", "High", "Medium", "Low"],
  },
  {
    value: "team",
    label: "Team",
    options: ["Design", "Engineering", "Marketing"],
  },
]

export default function AccordionMultiple() {
  return (
    <Accordion
      multiple
      defaultValue={["status", "priority"]}
      className="w-full max-w-64"
    >
      {filters.map((filter) => (
        <AccordionItem key={filter.value} value={filter.value}>
          <AccordionTrigger>{filter.label}</AccordionTrigger>
          <AccordionContent>
            {/* pt-1 keeps the first checkbox's 24px target clear of the trigger. */}
            <FieldGroup className="gap-2.5 pt-1">
              {filter.options.map((option, index) => {
                const id = `${filter.value}-${index}`
                return (
                  <Field key={id} orientation="horizontal">
                    <Checkbox id={id} defaultChecked={index === 1} />
                    <FieldLabel htmlFor={id} className="font-normal">
                      {option}
                    </FieldLabel>
                  </Field>
                )
              })}
            </FieldGroup>
          </AccordionContent>
        </AccordionItem>
      ))}
    </Accordion>
  )
}

With icons and summaries

Triggers accept any inline content. A leading icon and a one-line summary let people decide whether to open a section without opening it. Indent the panel (className on AccordionContent styles its inner wrapper) so body text aligns with the title.

import {
  KeyRoundIcon,
  MonitorSmartphoneIcon,
  ShieldCheckIcon,
} from "lucide-react"

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

const sections = [
  {
    value: "2fa",
    icon: ShieldCheckIcon,
    title: "Two-factor authentication",
    summary: "Enabled · Authenticator app",
    body: "You'll be asked for a code from your authenticator app when you sign in on a new device. Keep your recovery codes somewhere safe — they're the only way back in if you lose your phone.",
  },
  {
    value: "passkeys",
    icon: KeyRoundIcon,
    title: "Passkeys",
    summary: "2 passkeys",
    body: "Passkeys let you sign in with Face ID, Touch ID or a security key instead of a password. They can't be phished and are stored on your devices.",
  },
  {
    value: "sessions",
    icon: MonitorSmartphoneIcon,
    title: "Active sessions",
    summary: "3 devices",
    body: "MacBook Pro · Warsaw — current session. iPhone 16 · Warsaw — 2 hours ago. Chrome on Windows · Berlin — 3 days ago.",
  },
]

export default function AccordionWithIcons() {
  return (
    <Accordion
      defaultValue={["2fa"]}
      className="w-full max-w-md rounded-xl border bg-card px-4 shadow-raised"
    >
      {sections.map((section) => (
        <AccordionItem key={section.value} value={section.value}>
          <AccordionTrigger>
            <span className="flex items-center gap-3">
              <span className="flex size-8 shrink-0 items-center justify-center rounded-lg border bg-surface text-muted-foreground">
                <section.icon className="size-4" aria-hidden="true" />
              </span>
              <span className="flex flex-col gap-0.5">
                <span>{section.title}</span>
                <span className="text-body-xs text-muted-foreground">
                  {section.summary}
                </span>
              </span>
            </span>
          </AccordionTrigger>
          <AccordionContent className="pl-11">{section.body}</AccordionContent>
        </AccordionItem>
      ))}
    </Accordion>
  )
}

Disabled item

Disable an item that exists but isn't available, and say why in the label. The trigger stays visible, is announced as disabled and can't be toggled.

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

export default function AccordionDisabled() {
  return (
    <Accordion className="w-full max-w-sm">
      <AccordionItem value="profile">
        <AccordionTrigger>Profile</AccordionTrigger>
        <AccordionContent>
          Your name, photo and bio are visible to everyone in the workspace.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="sso" disabled>
        <AccordionTrigger>Single sign-on (Enterprise)</AccordionTrigger>
        <AccordionContent>
          Connect Okta, Entra ID or any SAML 2.0 provider.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="notifications">
        <AccordionTrigger>Notifications</AccordionTrigger>
        <AccordionContent>
          Choose which activity sends you an email or a push notification.
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  )
}

Controlled

Control value to open sections from elsewhere — Expand all, deep links, or restoring what someone had open. onValueChange receives the next array of open values.

1 of 3 expanded

"use client"

import * as React from "react"

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

const code =
  "rounded-md border bg-surface px-1 py-0.5 font-mono text-xs text-foreground"

const steps = [
  {
    value: "install",
    title: "Install the CLI",
    body: (
      <>
        Run the installer once per machine. It adds the{" "}
        <code className={code}>prfct</code> command to your path.
      </>
    ),
  },
  {
    value: "link",
    title: "Link your project",
    body: (
      <>
        Inside your repository, run <code className={code}>prfct link</code> and
        pick the workspace to connect to.
      </>
    ),
  },
  {
    value: "deploy",
    title: "Deploy",
    body: "Push to your main branch. Every commit gets a preview URL; main goes to production.",
  },
]

export default function AccordionControlled() {
  const [value, setValue] = React.useState<string[]>(["install"])
  const allOpen = value.length === steps.length

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <div className="flex items-center justify-between">
        <span className="text-sm text-muted-foreground">
          {`${value.length} of ${steps.length} expanded`}
        </span>
        <Button
          variant="ghost"
          size="xs"
          onClick={() => setValue(allOpen ? [] : steps.map((s) => s.value))}
        >
          {allOpen ? "Collapse all" : "Expand all"}
        </Button>
      </div>
      <Accordion multiple value={value} onValueChange={setValue}>
        {steps.map((step) => (
          <AccordionItem key={step.value} value={step.value}>
            <AccordionTrigger>{step.title}</AccordionTrigger>
            <AccordionContent>{step.body}</AccordionContent>
          </AccordionItem>
        ))}
      </Accordion>
    </div>
  )
}
const [value, setValue] = React.useState<string[]>(["install"])

<Accordion multiple value={value} onValueChange={setValue}>
  …
</Accordion>

Motion

Panels animate their real height with the base duration and standard easing, and fade their content in the same beat; the chevron rotates in step. Nothing is scaled or clipped, so text never smears. With prefers-reduced-motion, sections open instantly.

Guidelines

When to use

  • To shorten long pages whose sections are relevant to only some readers: FAQs, product details, release notes.
  • To group related settings or filters that people revisit selectively.
  • On small screens, to keep secondary content one tap away instead of scrolled past.

When not to use

  • For content everyone needs. Hiding it costs a click and hurts findability — show it.
  • For a single show/hide region — use a Collapsible.
  • To switch between views of the same data — use Tabs.
  • For navigation — use a Sidebar or Navigation Menu.

Write triggers people can scan

Triggers are headings. Keep them short, front-load the keyword, and make them mutually exclusive so people can tell which one holds their answer. In an FAQ, phrase them as the question the reader would ask.

DoShort, specific headings that predict what's inside.

Don’tVague labels force people to open every section to find anything.

Keep it flat

Don't nest accordions. A second level of disclosure hides content behind two clicks and makes the open/closed state hard to follow. If the content needs hierarchy, give it its own page.

Never hide what decides the task

Prices, errors, required fields and anything that changes a decision must stay visible. An accordion is for supporting content; if closing a section could make someone miss something important, it shouldn't be in one.

Accessibility

Each trigger is a <button> inside an <h3>, with aria-expanded and, while open, aria-controls pointing at its panel. Panels have role="region" and are labelled by their trigger, so screen readers announce which section they're in.

KeyBehavior
Tab
Moves focus to the next trigger, or into the open panel's focusable content.
ShiftTab
Moves focus to the previous trigger or focusable element.
EnterSpace
Expands or collapses the focused section.
  • No arrow-key navigation. Following the 2025 update to the WAI-ARIA Authoring Practices, accordion headers are ordinary tab stops; arrow keys are not intercepted.
  • Heading level. Triggers sit in an <h3>. If that breaks your page outline, change the render of AccordionPrimitive.Header in your copy of the component (for example render={<h2 />}).
  • Find in page. Set hiddenUntilFound on the root (or a panel) and the browser's find-in-page can search closed sections and open them on a match.
  • Disabled items stay in the reading order and are announced as disabled, so people know the section exists.

API reference

Accordion

Groups the items and manages which are open. Renders a <div>. Accepts every prop of Base UI Accordion.Root.

PropTypeDefault
defaultValue

Values of the items open on first render (uncontrolled).

any[]No default
value

Values of the open items (controlled). Pair with onValueChange.

any[]No default
onValueChange

Called with the next array of open values when an item is toggled.

(value: any[], details) => voidNo default
multiple

Allows more than one item to be open at the same time.

booleanfalse
disabled

Disables every item.

booleanfalse
hiddenUntilFound

Keeps closed panels in the DOM with hidden="until-found" so find-in-page can reveal them.

booleanfalse
keepMounted

Keeps closed panels in the DOM. Ignored when hiddenUntilFound is set.

booleanfalse

AccordionItem

One heading and its panel. Renders a <div>.

PropTypeDefault
value

Identifies the item in value and defaultValue. Generated when omitted — set it whenever you control the accordion.

anyNo default
disabled

Prevents the item from being toggled.

booleanfalse
onOpenChange

Called when this item opens or closes.

(open: boolean, details) => voidNo default

AccordionTrigger

The button that toggles its item, wrapped in an <h3>. Includes the rotating chevron. Accepts every prop of Base UI Accordion.Trigger.

AccordionContent

The collapsible panel. className is a string that styles the padded content inside the panel (padding, typography), while the outer panel handles the height animation. Accepts every other prop of Base UI Accordion.Panel, including hiddenUntilFound and keepMounted.