# Spring Accordion (`spring-accordion`)

> An editorial accordion — spring-driven height, answers that rise out of a blur, and an index numeral that ignites on open.

- **Docs:** https://www.mellowui.com/components/spring-accordion
- **Markdown:** https://www.mellowui.com/components/spring-accordion.md
- **Registry:** https://www.mellowui.com/r/spring-accordion.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/spring-accordion
- **Categories:** interactive, display, animation
- **Dependencies:** motion

## AI prompt

Add a SpringAccordion component from the mellow library — an accordion whose panels open with a spring-animated height while the answer text rises out of a blur, and whose mono index numerals (01, 02…) turn accent-blue on the open row. Pass an `items` array of { title, content }. Use multiple={true} to allow several panels open at once and defaultOpen={[0]} to open the first panel on mount. It respects prefers-reduced-motion.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/spring-accordion.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `items` | `{ title: string; content: ReactNode }[]` | — | The accordion rows — a title and the panel content. |
| `multiple` | `boolean` | `false` | Allow multiple panels open at once. |
| `defaultOpen` | `number[]` | `[]` | Indices of panels open on mount. |
| `numbered` | `boolean` | `true` | Show the 01 / 02 index column. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

export interface SpringAccordionItem {
  title: string;
  content: React.ReactNode;
}

export interface SpringAccordionProps {
  items: SpringAccordionItem[];
  /** Allow multiple panels open at once. */
  multiple?: boolean;
  /** Indices of panels open on mount. */
  defaultOpen?: number[];
  /** Show the 01 / 02 index column. */
  numbered?: boolean;
  className?: string;
  style?: React.CSSProperties;
}

/**
 * An editorial accordion — spring-driven height, answers that rise out of a
 * blur, and an index numeral that ignites on open.
 */
export function SpringAccordion({
  items,
  multiple = false,
  defaultOpen = [],
  numbered = true,
  className,
  style,
}: SpringAccordionProps) {
  const [open, setOpen] = useState<Set<number>>(() => new Set(defaultOpen));
  const reduced = useReducedMotion();
  const baseId = useId();

  const toggle = (i: number) => {
    setOpen((prev) => {
      const next = new Set(multiple ? prev : [...prev].filter((n) => n === i));
      if (next.has(i)) next.delete(i);
      else next.add(i);
      return next;
    });
  };

  const spring = reduced
    ? ({ duration: 0 } as const)
    : ({ type: "spring", stiffness: 260, damping: 32 } as const);

  return (
    <div
      className={["border-t border-[var(--rule)]", className].filter(Boolean).join(" ")}
      style={style}
    >
      {items.map((item, i) => {
        const isOpen = open.has(i);
        const panelId = `${baseId}-panel-${i}`;
        return (
          <div key={i} className="border-b border-[var(--rule)]">
            <button
              type="button"
              aria-expanded={isOpen}
              aria-controls={panelId}
              onClick={() => toggle(i)}
              className="group flex w-full cursor-pointer items-baseline gap-5 py-5 text-left"
            >
              {numbered && (
                <span
                  className={[
                    "w-7 shrink-0 [font-family:var(--font-mono)] text-xs tabular-nums transition-colors duration-300",
                    isOpen
                      ? "text-[oklch(0.65_0.25_250)]"
                      : "text-[rgba(var(--ink-rgb),0.35)]",
                  ].join(" ")}
                >
                  {String(i + 1).padStart(2, "0")}
                </span>
              )}
              <span className="flex-1 [font-family:var(--font-sans)] text-lg font-medium tracking-[-0.02em] text-[var(--ink)] transition-opacity duration-200 group-hover:opacity-70">
                {item.title}
              </span>
              <motion.span
                aria-hidden="true"
                animate={{ rotate: isOpen ? 45 : 0 }}
                transition={spring}
                className="relative h-3.5 w-3.5 shrink-0 self-center"
              >
                <span className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 bg-[var(--ink)]" />
                <span className="absolute top-0 left-1/2 h-full w-px -translate-x-1/2 bg-[var(--ink)]" />
              </motion.span>
            </button>

            <AnimatePresence initial={false}>
              {isOpen && (
                <motion.div
                  id={panelId}
                  role="region"
                  initial={{ height: 0, opacity: 0 }}
                  animate={{ height: "auto", opacity: 1 }}
                  exit={{ height: 0, opacity: 0 }}
                  transition={spring}
                  className="overflow-hidden"
                >
                  <motion.div
                    initial={reduced ? false : { y: 14, filter: "blur(6px)", opacity: 0 }}
                    animate={{ y: 0, filter: "blur(0px)", opacity: 1 }}
                    exit={
                      reduced
                        ? undefined
                        : { y: 8, filter: "blur(4px)", opacity: 0 }
                    }
                    transition={
                      reduced
                        ? { duration: 0 }
                        : { type: "spring", stiffness: 200, damping: 28 }
                    }
                    className={[
                      "pr-9 pb-6 [font-family:var(--font-sans)] text-[0.9375rem] leading-relaxed text-[rgba(var(--ink-rgb),0.65)]",
                      numbered ? "pl-12" : "",
                    ].filter(Boolean).join(" ")}
                  >
                    {item.content}
                  </motion.div>
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        );
      })}
    </div>
  );
}

export default SpringAccordion;

```

## Demo

```tsx
"use client";

import React from "react";
import { SpringAccordion } from "../mellow/spring-accordion";

const items = [
  {
    title: "What is Mellow UI?",
    content:
      "A copy-paste component library with an editorial point of view. Every component is self-contained — install it with the shadcn CLI, then the code is yours to edit.",
  },
  {
    title: "Do I need Tailwind?",
    content:
      "Yes — components are styled with Tailwind v4 classes and a small set of design-token CSS variables that flip automatically between light and dark themes.",
  },
  {
    title: "How do the animations work?",
    content:
      "Motion for React drives the springs. This accordion animates height with a spring while the answer rises out of a blur — and it respects prefers-reduced-motion.",
  },
  {
    title: "Can I use it in commercial projects?",
    content:
      "Absolutely. Once a component is in your codebase it's just your code — no license keys, no runtime, no strings attached.",
  },
];

export default function SpringAccordionDemo() {
  return (
    <div className="w-full max-w-xl px-6 py-10">
      <SpringAccordion items={items} defaultOpen={[0]} />
    </div>
  );
}

```
