# Collapsible

One section that opens and closes: the height springs open, the content fades and lifts in, and the chevron turns. Controlled or uncontrolled.

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

## Install

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

## API — Collapsible
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `trigger` | `ReactNode` | — |  |
| `children` | `ReactNode` | — |  |
| `open / defaultOpen` | `boolean` | `false` |  |
| `onOpenChange` | `(open: boolean) => void` | — |  |
| `className` | `string` | — |  |

## Example — Collapsible

```tsx
import { Collapsible } from "@/components/ui/collapsible";

export default function CollapsibleDemo({ defaultOpen = true }: { defaultOpen?: boolean }) {
  return (
    <div className="w-full max-w-sm">
      <Collapsible key={String(defaultOpen)} defaultOpen={defaultOpen} trigger="What happens when my trial ends?">
        Your workspace switches to the free plan. Nothing is deleted, and you can upgrade at any time to pick up where you left off.
      </Collapsible>
    </div>
  );
}
```

## Source — components/ui/collapsible.tsx

```tsx
"use client";

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

export type CollapsibleProps = {
  /** The always-visible trigger row. */
  trigger: React.ReactNode;
  children: React.ReactNode;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  className?: string;
};

/** One section that opens and closes: the height springs open, the content fades and lifts in, and the chevron turns. */
export function Collapsible({ trigger, children, open: openProp, defaultOpen = false, onOpenChange, className }: CollapsibleProps) {
  const reduce = useReducedMotion();
  const id = React.useId();
  const [inner, setInner] = React.useState(defaultOpen);
  const open = openProp ?? inner;
  const toggle = () => {
    setInner(!open);
    onOpenChange?.(!open);
  };

  return (
    <div className={cn("w-full rounded-xl border bg-card text-card-foreground", className)}>
      <button type="button" aria-expanded={open} aria-controls={id} onClick={toggle} className="flex w-full cursor-pointer items-center justify-between gap-3 rounded-xl px-4 py-3 text-left text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring">
        {trigger}
        <motion.span animate={{ rotate: open ? 180 : 0 }} transition={{ type: "spring", stiffness: 400, damping: 28 }} className="text-muted-foreground">
          <ChevronDown className="size-4" />
        </motion.span>
      </button>
      <AnimatePresence initial={false}>
        {open && (
          <motion.div
            id={id}
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={reduce ? { duration: 0 } : { height: { type: "spring", stiffness: 320, damping: 34 }, opacity: { duration: 0.2 } }}
            className="overflow-hidden"
          >
            <motion.div initial={{ y: reduce ? 0 : -6 }} animate={{ y: 0 }} exit={{ y: reduce ? 0 : -6 }} className="px-4 pb-4 text-sm leading-6 text-muted-foreground">
              {children}
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}
```