# Accordion

> Single or multiple accordion with smooth grid-rows height animation, chevron rotation and full keyboard navigation.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/accordion
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/accordion.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/accordion-demo.tsx"
import { Accordion } from "@/components/accordion"

const faq = [
  {
    value: "billing",
    title: "How does billing work?",
    content: "You are billed monthly per active seat. Add or remove teammates at any time and the difference is prorated on your next invoice.",
  },
  {
    value: "export",
    title: "Can I export my data?",
    content: "Yes. Every workspace can be exported as CSV or JSON from Settings, and exports are available on every plan.",
  },
  {
    value: "security",
    title: "Is my data encrypted?",
    content: "Data is encrypted in transit with TLS 1.3 and at rest with AES-256. Enterprise plans also support customer-managed keys.",
  },
  {
    value: "legacy",
    title: "Legacy plan (unavailable)",
    content: "This plan is no longer offered.",
    disabled: true,
  },
]

export default function AccordionDemo() {
  return (
    <div className="grid w-full max-w-lg gap-8">
      <section className="grid gap-3">
        <p className="text-sm font-medium text-muted-foreground">Single (collapses others)</p>
        <Accordion items={faq} type="single" defaultValue={["billing"]} />
      </section>
      <section className="grid gap-3">
        <p className="text-sm font-medium text-muted-foreground">Multiple</p>
        <Accordion items={faq.slice(0, 3)} type="multiple" defaultValue={["export", "security"]} />
      </section>
    </div>
  )
}
```

## Source

```tsx title="components/accordion.tsx"
"use client"

import * as React from "react"
import { ChevronDown } from "lucide-react"

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

export interface AccordionItemData {
  value: string
  title: React.ReactNode
  content: React.ReactNode
  disabled?: boolean
}

export interface AccordionProps extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> {
  items: AccordionItemData[]
  /** "single" keeps one panel open at a time, "multiple" allows many */
  type?: "single" | "multiple"
  defaultValue?: string[]
  /** Controlled open values */
  value?: string[]
  onValueChange?: (value: string[]) => void
}

export function Accordion({
  items,
  type = "single",
  defaultValue = [],
  value,
  onValueChange,
  className,
  ...props
}: AccordionProps) {
  const baseId = React.useId()
  const rootRef = React.useRef<HTMLDivElement>(null)
  const [inner, setInner] = React.useState<string[]>(defaultValue)
  const open = value ?? inner

  function toggle(itemValue: string) {
    const isOpen = open.includes(itemValue)
    let next: string[]
    if (isOpen) next = open.filter((v) => v !== itemValue)
    else next = type === "single" ? [itemValue] : [...open, itemValue]
    if (value === undefined) setInner(next)
    onValueChange?.(next)
  }

  function onKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
    const triggers = Array.from(
      rootRef.current?.querySelectorAll<HTMLButtonElement>("[data-accordion-trigger]:not(:disabled)") ?? []
    )
    const index = triggers.indexOf(event.currentTarget)
    if (index < 0) return
    let target: number | null = null
    if (event.key === "ArrowDown") target = (index + 1) % triggers.length
    else if (event.key === "ArrowUp") target = (index - 1 + triggers.length) % triggers.length
    else if (event.key === "Home") target = 0
    else if (event.key === "End") target = triggers.length - 1
    if (target === null) return
    event.preventDefault()
    triggers[target].focus()
  }

  return (
    <div
      ref={rootRef}
      className={cn("w-full divide-y overflow-hidden rounded-xl border bg-card text-card-foreground", className)}
      {...props}
    >
      {items.map((item) => {
        const isOpen = open.includes(item.value)
        const triggerId = `${baseId}-trigger-${item.value}`
        const panelId = `${baseId}-panel-${item.value}`
        return (
          <div key={item.value}>
            <h3 className="m-0">
              <button
                type="button"
                id={triggerId}
                data-accordion-trigger=""
                aria-expanded={isOpen}
                aria-controls={panelId}
                disabled={item.disabled}
                onClick={() => toggle(item.value)}
                onKeyDown={onKeyDown}
                className="flex w-full items-center justify-between gap-4 px-4 py-3.5 text-left text-sm font-medium transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50"
              >
                {item.title}
                <ChevronDown
                  aria-hidden
                  className={cn(
                    "size-4 shrink-0 text-muted-foreground transition-transform duration-300",
                    isOpen && "rotate-180"
                  )}
                />
              </button>
            </h3>
            <div
              id={panelId}
              role="region"
              aria-labelledby={triggerId}
              inert={!isOpen}
              className={cn(
                "grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none",
                isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
              )}
            >
              <div className="min-h-0 overflow-hidden">
                <div className="px-4 pb-4 text-sm leading-relaxed text-muted-foreground">{item.content}</div>
              </div>
            </div>
          </div>
        )
      })}
    </div>
  )
}
```
