Accordion
A wrapper for Base UI Accordion with multiple open panels, indicator placement, controlled state, per-item and global prop overrides.
Basic
Single open — only one panel expands at a time
Multiple — several panels can be open simultaneously
Left indicator — chevron on the left side
State
Default open — item-1 expanded on mount
Yes. It adheres to the WAI-ARIA design pattern.
Disabled item — middle item non-interactive
Disabled root — all items non-interactive
Panel Behaviour
Keep mounted — panel DOM preserved on close, state persists
Count: 0(persists when closed)
Content here.
Hidden until found — closed panels findable by browser Ctrl+F
This findable text is discoverable by browser search even when panel is closed.
Regular content.
Controlled
Controlled — open state managed externally via value/onValueChange
Yes. It adheres to the WAI-ARIA design pattern.
Custom Style
Per-item content colours, bold triggers, left border
Custom heading level — triggers render as h2 elements
Installation
npx shadcn@latest add @glrk-ui/accordionIf you haven't set up the prerequisites yet, check out Prerequest section.
Copy and paste the following code into your project.
'use client'
import { Accordion as AccordionPrimitive } from '@base-ui/react/accordion'
import { ChevronDownIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
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 AccordionHeader({ className, ...props }: AccordionPrimitive.Header.Props) {
return (
<AccordionPrimitive.Header
data-slot="accordion-header"
className={cn('flex', className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
indicatorAt = 'right',
headerProps,
...props
}: AccordionPrimitive.Trigger.Props & {
indicatorAt?: indicatorAtT
headerProps?: AccordionPrimitive.Header.Props
}) {
const icon = indicatorAt !== '' && (
<ChevronDownIcon
data-slot="accordion-trigger-icon"
className={cn(
'pointer-events-none shrink-0 transition-transform duration-300 group-aria-expanded/accordion-trigger:rotate-180',
indicatorAt === 'right' && 'ml-auto',
)}
/>
)
return (
<AccordionHeader {...headerProps}>
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
'group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground cursor-pointer',
className,
)}
{...props}
>
{indicatorAt === 'left' && icon}
{children}
{indicatorAt === 'right' && icon}
</AccordionPrimitive.Trigger>
</AccordionHeader>
)
}
function AccordionContent({ className, children, ...props }: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="overflow-hidden text-sm h-(--accordion-panel-height) transition-[height] ease-out 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>
)
}
type accordionSharedT = {
triggerCls?: string
contentCls?: string
indicatorAt?: indicatorAtT
itemProps?: Omit<AccordionPrimitive.Item.Props, 'value' | 'className'>
triggerProps?: Omit<AccordionPrimitive.Trigger.Props, 'className' | 'children'> & {
headerProps?: AccordionPrimitive.Header.Props
}
contentProps?: Omit<AccordionPrimitive.Panel.Props, 'className' | 'children'>
}
type accordionItemT = {
value: string
trigger: React.ReactNode
content: React.ReactNode
className?: string
} & accordionSharedT
type accordionItemsT = accordionItemT[]
type accordionWrapperProps = {
items: accordionItemsT
itemCls?: string
} & accordionSharedT &
React.ComponentProps<typeof AccordionPrimitive.Root>
function AccordionWrapper({
items,
itemCls,
triggerCls,
contentCls,
indicatorAt = 'right',
itemProps: globalItemProps,
triggerProps: globalTriggerProps,
contentProps: globalContentProps,
...props
}: accordionWrapperProps) {
return (
<Accordion {...props}>
{items.map(({ triggerProps, contentProps, itemProps, ...item }) => (
<AccordionItem
key={item.value}
value={item.value}
className={cn(itemCls, item.className)}
{...globalItemProps}
{...itemProps}
>
<AccordionTrigger
className={cn('items-center justify-start gap-2', triggerCls, item.triggerCls)}
indicatorAt={item.indicatorAt ?? indicatorAt}
{...globalTriggerProps}
{...triggerProps}
>
{item.trigger}
</AccordionTrigger>
<AccordionContent
className={cn(contentCls, item.contentCls)}
{...globalContentProps}
{...contentProps}
>
{item.content}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)
}
export {
Accordion,
AccordionItem,
AccordionHeader,
AccordionTrigger,
AccordionContent,
AccordionWrapper,
type accordionItemT,
type accordionItemsT,
}
Usage
Basic
import { AccordionWrapper } from "@/components/ui/accordion"
<AccordionWrapper
items={[
{ value: "q1", trigger: "Is it accessible?", content: "Yes. Adheres to WAI-ARIA." },
{ value: "q2", trigger: "Is it styled?", content: "Yes. Matches other components." },
]}
/>Multiple open panels
<AccordionWrapper items={items} multiple />Indicator placement
<AccordionWrapper items={items} indicatorAt="left" /> // chevron left
<AccordionWrapper items={items} indicatorAt="right" /> // default
<AccordionWrapper items={items} indicatorAt="" /> // no chevronPer-item override:
<AccordionWrapper
items={[
{ value: "a", trigger: "Right indicator", content: "..." },
{ value: "b", trigger: "Left indicator", content: "...", indicatorAt: "left" },
]}
/>Default open
<AccordionWrapper items={items} defaultValue={["q1"]} />Controlled
const [value, setValue] = useState<string[]>(["q1"])
<AccordionWrapper
items={items}
value={value}
onValueChange={setValue}
multiple
/>Disabled
// Disable entire accordion
<AccordionWrapper items={items} disabled />
// Disable specific item
<AccordionWrapper
items={[
{ value: "a", trigger: "Enabled", content: "..." },
{ value: "b", trigger: "Disabled", content: "...", itemProps: { disabled: true } },
]}
/>Keep panels mounted
Preserves DOM when closed — useful for forms or avoiding re-mount cost:
<AccordionWrapper items={items} contentProps={{ keepMounted: true }} />Hidden until found
Enables browser Ctrl+F search inside closed panels:
<AccordionWrapper items={items} contentProps={{ hiddenUntilFound: true }} />Custom heading level
Default heading is <h3>. Use triggerProps.headerProps.render to change:
// All items use h2
<AccordionWrapper items={items} triggerProps={{ headerProps: { render: <h2 /> } }} />
// Per-item h1
<AccordionWrapper
items={[
{ value: "a", trigger: "Page title", content: "...", triggerProps: { headerProps: { render: <h1 /> } } },
]}
/>Global vs per-item overrides
Global props apply to all items. Per-item props override globals:
<AccordionWrapper
items={[
{ value: "a", trigger: "Global style", content: "..." },
{ value: "b", trigger: "Custom style", content: "...", triggerCls: "text-primary" },
]}
triggerCls="font-semibold"
indicatorAt="left"
contentProps={{ keepMounted: true }}
/>Reference
accordionSharedT
accordionItemT
Prop
Type
All accordionSharedT fields (triggerCls, contentCls, indicatorAt, itemProps, triggerProps, contentProps) can be set per-item to override globals.
AccordionWrapper
Prop
Type