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/accordionNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
const QUESTIONS = [
{
value: "seats",
question: "How are seats counted?",
answer:
"Every member who signed in during the billing period is a seat. Guests with view-only access are free.",
},
{
value: "upgrades",
question: "What happens when we upgrade mid-cycle?",
answer: "The new plan starts at once, and the unused days on the old one are credited to the next invoice.",
},
{
value: "invoices",
question: "Where do invoices go?",
answer: "To the workspace's billing contact as a PDF, and to Billing › Invoices, where they stay for seven years.",
},
]
export default function AccordionDemo() {
return (
<Accordion defaultValue={["seats"]} className="w-full max-w-md">
{QUESTIONS.map((item) => (
<AccordionItem key={item.value} value={item.value}>
<AccordionTrigger>{item.question}</AccordionTrigger>
<AccordionContent>{item.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
)
}Several open at once
multiple lets any number of sections stay open. An order's details start with two of them.
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
const SECTIONS = [
{
value: "shipping",
title: "Shipping",
body: "Blue Harbor Logistics, Pier 4, 3011 AB Rotterdam. Express courier, arriving Friday, September 26.",
},
{ value: "payment", title: "Payment", body: "Card ending 4242. $214.00 captured on September 23." },
{ value: "items", title: "Items", body: "Walnut desk organiser × 1, ceramic mug × 2." },
]
// An order's details, where the reader compares one section with another:
// `multiple` lets any number stay open, and two start that way.
export default function AccordionMultiple() {
return (
<Accordion multiple defaultValue={["shipping", "payment"]} className="w-full max-w-md">
{SECTIONS.map((section) => (
<AccordionItem key={section.value} value={section.value}>
<AccordionTrigger>{section.title}</AccordionTrigger>
<AccordionContent className="text-muted-foreground">{section.body}</AccordionContent>
</AccordionItem>
))}
</Accordion>
)
}With icons
Each topic led by its icon. The icon is decoration; the words name the section.
import { CreditCardIcon, ShieldCheckIcon, UsersIcon } from "lucide-react"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
const TOPICS = [
{
value: "billing",
icon: CreditCardIcon,
title: "Billing and invoices",
body: "Invoices go to the billing contact on the 1st, and every past one is under Billing › Invoices.",
},
{
value: "seats",
icon: UsersIcon,
title: "Seats and roles",
body: "Owners and admins manage members; a viewer seat is free and can't edit anything.",
},
{
value: "security",
icon: ShieldCheckIcon,
title: "Security and single sign-on",
body: "Two-step verification is on for everyone, and SAML sign-on comes with the Enterprise plan.",
},
]
// A help centre's topics, each led by its icon. The icon and the words share
// one span so they stay together, and the icon is decoration — the words name
// the section.
export default function AccordionIcons() {
return (
<Accordion defaultValue={["billing"]} className="w-full max-w-md">
{TOPICS.map((topic) => (
<AccordionItem key={topic.value} value={topic.value}>
<AccordionTrigger>
<span className="flex items-center gap-2.5">
<topic.icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
{topic.title}
</span>
</AccordionTrigger>
<AccordionContent className="ps-6.5 text-muted-foreground">{topic.body}</AccordionContent>
</AccordionItem>
))}
</Accordion>
)
}With a summary line
A closed section says what it is set to, so the list reads as a summary until something needs changing.
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
const SETTINGS = [
{
value: "retention",
title: "Data retention",
summary: "Events kept for 13 months",
body: "Older events are deleted on the first of each month. Invoices and the audit log follow their own rules.",
},
{
value: "exports",
title: "Scheduled exports",
summary: "CSV every day at 06:00 UTC",
body: "The export lands in the shared drive and is emailed to Maren Kovač.",
},
{
value: "audit",
title: "Audit log",
summary: "Every admin action, kept 7 years",
body: "Search it under Settings › Audit log, or stream it to your own store.",
},
]
// Settings that are mostly left alone: each closed section says what it is
// set to, so the page reads as a summary until something needs changing.
export default function AccordionSummary() {
return (
<Accordion className="w-full max-w-md">
{SETTINGS.map((setting) => (
<AccordionItem key={setting.value} value={setting.value}>
<AccordionTrigger>
<span className="flex flex-col gap-0.5">
{/* The space keeps the two lines apart in the trigger's name. */}
{setting.title}{" "}
<span className="font-normal text-muted-foreground">{setting.summary}</span>
</span>
</AccordionTrigger>
<AccordionContent className="flex flex-col items-start gap-2 text-muted-foreground">
{setting.body}
<Button variant="outline" size="xs">
Change{" "}
<span className="sr-only">{setting.title.toLowerCase()}</span>
</Button>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)
}In a panel
Inside the kit's panel: the items padded from its edge, their rules running the full width.
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
const QUESTIONS = [
{
value: "region",
question: "Where is Northwind's data stored?",
answer: "In Frankfurt (eu-central-1), with a standby copy in Dublin. Neither leaves the EU.",
},
{
value: "encryption",
question: "Is it encrypted?",
answer: "At rest with AES-256 and in transit with TLS 1.3. Keys rotate every 90 days.",
},
{
value: "access",
question: "Who on the support team can see it?",
answer: "Two on-call engineers, only during an incident you have opened, and every access lands in your audit log.",
},
]
// The accordion inside the kit's panel: the card plane and its hairline ring,
// the items padded from the edge while their rules still run the full width.
export default function AccordionBordered() {
return (
<Accordion defaultValue={["region"]} className="panel w-full max-w-md">
{QUESTIONS.map((item) => (
<AccordionItem key={item.value} value={item.value} className="px-3.5">
<AccordionTrigger>{item.question}</AccordionTrigger>
<AccordionContent className="text-muted-foreground">{item.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
)
}Separate panels
Each section a frame of its own. The open one rises to the card plane rather than taking an outline.
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
const STEPS = [
{ value: "domain", title: "Verify northwind.example", body: "Add the TXT record we sent to your DNS. It usually takes a few minutes to show." },
{ value: "idp", title: "Connect the identity provider", body: "Paste the SAML metadata URL from your provider, or upload its XML file." },
{ value: "enforce", title: "Require single sign-on", body: "Members sign in through your provider from then on. Owners keep a password as a way back in." },
]
// Each section a panel of its own. A closed one sits on the surface plane
// with the card frames; the open one rises to the card plane — the step the
// reader is on, told by the plane rather than by an outline.
export default function AccordionSeparate() {
return (
<Accordion defaultValue={["idp"]} className="w-full max-w-md gap-2">
{STEPS.map((step, index) => (
<AccordionItem key={step.value} value={step.value} className="frame px-3.5 not-last:border-b-0 data-open:bg-card">
<AccordionTrigger>
<span className="flex items-baseline gap-2">
<span className="text-xs text-muted-foreground tabular-nums">{index + 1}</span>
{step.title}
</span>
</AccordionTrigger>
<AccordionContent className="ps-4 text-muted-foreground">{step.body}</AccordionContent>
</AccordionItem>
))}
</Accordion>
)
}Nested
Topics that open onto their own questions, each level an accordion of its own.
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
const TOPICS = [
{
value: "billing",
title: "Billing",
questions: [
{ value: "seats", question: "How are seats counted?", answer: "Every member who signed in during the period. Viewers are free." },
{ value: "transfer", question: "Can we pay by bank transfer?", answer: "Yes, on annual plans. The invoice carries the account details." },
],
},
{
value: "data",
title: "Data and privacy",
questions: [
{ value: "export", question: "Can we export everything?", answer: "Settings › Export builds one archive of every table, ready within the hour." },
{ value: "delete", question: "What happens when we leave?", answer: "Your data is kept for 30 days in case you come back, then deleted for good." },
],
},
]
// Topics that open onto their own questions. Each level is an accordion of
// its own, so opening a question leaves its topic open, and Tab walks from a
// topic into its questions and on to the next topic.
export default function AccordionNested() {
return (
<Accordion defaultValue={["billing"]} className="w-full max-w-md">
{TOPICS.map((topic) => (
<AccordionItem key={topic.value} value={topic.value}>
<AccordionTrigger>{topic.title}</AccordionTrigger>
<AccordionContent className="pb-1">
<Accordion defaultValue={topic.value === "billing" ? ["seats"] : []} className="rounded-lg bg-muted/50 px-3">
{topic.questions.map((item) => (
<AccordionItem key={item.value} value={item.value}>
<AccordionTrigger className="font-normal">{item.question}</AccordionTrigger>
<AccordionContent className="text-muted-foreground">{item.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)
}Disabled, with a reason
A section the plan doesn't include: still in the tab order, and described by why it won't open.
"use client"
import * as React from "react"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Badge } from "@/components/ui/badge"
// A section the plan doesn't include. The trigger stays in the tab order —
// Base UI keeps a disabled trigger focusable — and says why it won't open, so
// a keyboard reader isn't left pressing Enter on something inert.
export default function AccordionDisabled() {
const why = React.useId()
return (
<div className="flex w-full max-w-md flex-col gap-2">
<Accordion className="w-full">
<AccordionItem value="password">
<AccordionTrigger>Password rules</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
At least 12 characters, checked against known leaked passwords.
</AccordionContent>
</AccordionItem>
<AccordionItem value="two-step">
<AccordionTrigger>Two-step verification</AccordionTrigger>
<AccordionContent className="text-muted-foreground">Required for owners and admins from November 1.</AccordionContent>
</AccordionItem>
<AccordionItem value="saml" disabled>
<AccordionTrigger aria-describedby={why}>
<span className="flex items-center gap-2">
SAML single sign-on
<Badge variant="outline">Enterprise</Badge>
</span>
</AccordionTrigger>
<AccordionContent />
</AccordionItem>
</Accordion>
<p id={why} className="text-xs text-muted-foreground">
SAML single sign-on comes with the Enterprise plan. Northwind is on Team.
</p>
</div>
)
}Expand all
Controlled, so one button opens or closes every section and says which it will do next.
"use client"
import * as React from "react"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
const RELEASES = [
{ value: "2.14", title: "2.14 · Scheduled exports", notes: "Exports run on a schedule and land in the shared drive." },
{ value: "2.13", title: "2.13 · Audit log search", notes: "Filter the audit log by person, action and date range." },
{ value: "2.12", title: "2.12 · Faster CSV import", notes: "A 100,000-row import now finishes in under a minute." },
]
// Controlled: the open sections are state, so one button can open or close
// them all. It says which it will do next, and each trigger still reports its
// own state.
export default function AccordionExpandAll() {
const [open, setOpen] = React.useState<string[]>(["2.14"])
const all = open.length === RELEASES.length
return (
<div className="flex w-full max-w-md flex-col gap-1">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Release notes</h3>
<Button variant="ghost" size="xs" className="-me-2" onClick={() => setOpen(all ? [] : RELEASES.map((release) => release.value))}>
{all ? "Collapse all" : "Expand all"}
</Button>
</div>
<Accordion multiple value={open} onValueChange={setOpen}>
{RELEASES.map((release) => (
<AccordionItem key={release.value} value={release.value}>
<AccordionTrigger>{release.title}</AccordionTrigger>
<AccordionContent className="text-muted-foreground">{release.notes}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
)
}Right to left
The demo's questions in Arabic: the text starts at the right and the chevron keeps the inline end.
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
const QUESTIONS = [
{ value: "seats", question: "كيف تُحتسب المقاعد؟", answer: "كل عضو سجّل الدخول خلال فترة الفوترة يُحتسب مقعدًا. أما الضيوف بصلاحية العرض فقط فلا يُحتسبون." },
{ value: "upgrade", question: "ماذا يحدث عند الترقية في منتصف الدورة؟", answer: "تبدأ الخطة الجديدة فورًا، وتُخصم الأيام غير المستخدمة من الفاتورة التالية." },
{ value: "invoices", question: "أين تُرسل الفواتير؟", answer: "إلى جهة اتصال الفوترة في مساحة العمل بصيغة PDF، وتبقى في صفحة الفواتير سبع سنوات." },
]
// The demo's questions in Arabic. The question starts at the right and the
// chevron keeps the inline end, on the left, however long the line runs.
export default function AccordionRtl() {
return (
<Accordion dir="rtl" lang="ar" defaultValue={["seats"]} className="w-full max-w-md">
{QUESTIONS.map((item) => (
<AccordionItem key={item.value} value={item.value}>
<AccordionTrigger>{item.question}</AccordionTrigger>
<AccordionContent className="text-muted-foreground">{item.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| multiple | boolean | false | Lets any number of sections stay open at once. |
| value / defaultValue / onValueChange | string[] / string[] / (value: string[]) => void | — | The open sections, controlled or not — an array even when only one opens at a time. |
| AccordionItem.value | string | — | Names the section in the root's value. |
| AccordionItem.disabled | boolean | false | The section won't open. Its trigger stays focusable and dims, so say why beside it. |
| AccordionTrigger.headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | The 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
Registry
Source
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 }