Skip to main content
Back to discovery

Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.

Spring Accordion

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

MSource LandMellow UI

Why it stands out

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

Prompt

You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11 (install: `pnpm add motion`)

If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.

Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.

Copy-paste these files:

File location: components/spring-accordion-demo.tsx

File content: "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-4 py-3 sm:px-6 sm:py-10">
      <SpringAccordion items={items} defaultOpen={[0]} />
    </div>
  );
}



File location: components/mellow/spring-accordion.tsx

File content: "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-3 py-3 text-left sm:gap-5 sm:py-5"
            >
              {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-base font-medium sm:text-lg 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-4 pb-4 [font-family:var(--font-sans)] text-sm leading-relaxed text-[rgba(var(--ink-rgb),0.65)] sm:pr-9 sm:pb-6 sm:text-[0.9375rem]",
                      numbered ? "pl-9 sm:pl-12" : "",
                    ].filter(Boolean).join(" ")}
                  >
                    {item.content}
                  </motion.div>
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        );
      })}
    </div>
  );
}

export default SpringAccordion;



Usage notes:
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.

Discovery vocabulary

Related by governed terms

Continue comparing

More from Mellow UI