Glrk UI

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

Hidden until found — closed panels findable by browser Ctrl+F

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/accordion

If you haven't set up the prerequisites yet, check out Prerequest section.

Copy and paste the following code into your project.

ui/accordion.tsx
'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 chevron

Per-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

Prop

Type

accordionItemT

Prop

Type

All accordionSharedT fields (triggerCls, contentCls, indicatorAt, itemProps, triggerProps, contentProps) can be set per-item to override globals.

AccordionWrapper

Prop

Type