# Accordion

Bouncy accordion: height springs open, the chevron flips, and content fades up a beat later.

**Category:** Display  
**Exports:** `Accordion`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/accordion.json
```
The file lands at `components/ui/accordion.tsx`; import from `@/components/ui/accordion`.

## API — Accordion
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `items` | `{ value: string; title: ReactNode; content: ReactNode }[]` | — |  |
| `type` | `"single" \| "multiple"` | `"single"` |  |
| `defaultValue` | `string[]` | — |  |

## Example — Accordion

```tsx
import { Accordion } from "@/components/ui/accordion";

export default function AccordionDemo({ type = "single" }: { type?: "single" | "multiple" }) {
  return (
    <Accordion
      className="max-w-md"
      type={type}
      defaultValue={["what"]}
      items={[
        { value: "what", title: "What is Charcoal UI?", content: "A set of motion-first React components you copy into your project and own." },
        { value: "deps", title: "What does it depend on?", content: "React 19, Tailwind CSS 4 and Motion. Nothing else at runtime." },
        { value: "a11y", title: "Is it accessible?", content: "Yes. Real roles, keyboard support, visible focus, and every component respects reduced motion." },
      ]}
    />
  );
}
```

## Source — components/ui/accordion.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type AccordionItem = { value: string; title: React.ReactNode; content: React.ReactNode };

export type AccordionProps = {
  items: AccordionItem[];
  type?: "single" | "multiple";
  defaultValue?: string[];
  className?: string;
};

export function Accordion({ items, type = "single", defaultValue = [], className }: AccordionProps) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const [open, setOpen] = React.useState<string[]>(defaultValue);
  // Open-by-default panels appear without animating; see Tabs for why this isn't AnimatePresence initial={false}.
  const first = React.useRef(true);
  React.useEffect(() => {
    first.current = false;
  }, []);

  const toggle = (v: string) =>
    setOpen((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : type === "single" ? [v] : [...prev, v]));

  return (
    <div className={cn("w-full divide-y overflow-hidden rounded-2xl border bg-card", className)}>
      {items.map((item) => {
        const isOpen = open.includes(item.value);
        return (
          <div key={item.value}>
            <h3>
              <button
                type="button"
                id={`${id}-${item.value}-trigger`}
                aria-expanded={isOpen}
                aria-controls={`${id}-${item.value}-panel`}
                onClick={() => toggle(item.value)}
                className="flex w-full cursor-pointer items-center justify-between gap-4 px-5 py-4 text-left text-[15px] font-medium outline-none transition-colors hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
              >
                {item.title}
                <motion.span
                  aria-hidden
                  className="grid size-6 shrink-0 place-items-center rounded-full bg-secondary"
                  animate={{ rotate: isOpen ? 180 : 0 }}
                  transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 400, damping: 18 }}
                >
                  <svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
                    <path d="m6 9 6 6 6-6" />
                  </svg>
                </motion.span>
              </button>
            </h3>
            <AnimatePresence>
              {isOpen && (
                <motion.div
                  id={`${id}-${item.value}-panel`}
                  role="region"
                  aria-labelledby={`${id}-${item.value}-trigger`}
                  initial={first.current ? { height: "auto" } : { height: 0 }}
                  animate={{ height: "auto" }}
                  exit={{ height: 0, transition: { type: "spring", stiffness: 500, damping: 45 } }}
                  transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 22, mass: 0.8 }}
                  className="overflow-hidden"
                >
                  <motion.div
                    initial={first.current ? false : { opacity: 0, y: reduce ? 0 : -6, filter: "blur(3px)" }}
                    animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                    exit={{ opacity: 0, transition: { duration: 0.1 } }}
                    transition={{ delay: reduce ? 0 : 0.06, duration: 0.25 }}
                    className="px-5 pb-5 text-sm leading-6 text-muted-foreground"
                  >
                    {item.content}
                  </motion.div>
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        );
      })}
    </div>
  );
}
```