Overview

An accordion whose rows open on a spring: the panel's height eases out with a hint of bounce, the text rises in from a soft blur, and the + folds into a −. The open row lifts away from its neighbours with a larger radius and a soft shadow, and its icon turns dark. Clicking again mid-animation reverses smoothly from wherever it is. Arrow keys, Home and End move between headers.


Installation

Use the CLI to install the component automatically:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/spring-accordion.json

Install dependencies:

$ pnpm add motion lucide-react

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/spring-accordion.tsx
tsx
"use client";
import React, { useCallback, useId, useRef, useState } from "react";import { AnimatePresence, motion, useReducedMotion } from "motion/react";import { cn } from "@/lib/utils";
export type SpringAccordionItem = {  value: string;  title: string;  /** Short line under the title. */  subtitle?: string;  icon?: React.ReactNode;  children: React.ReactNode;  disabled?: boolean;};
type SpringAccordionBase = {  items: SpringAccordionItem[];  className?: string;};
type SpringAccordionSingle = {  type?: "single";  value?: string;  defaultValue?: string;  onValueChange?: (value: string) => void;};
type SpringAccordionMultiple = {  type: "multiple";  value?: string[];  defaultValue?: string[];  onValueChange?: (value: string[]) => void;};
export type SpringAccordionProps = SpringAccordionBase &  (SpringAccordionSingle | SpringAccordionMultiple);
const HEIGHT_SPRING = { type: "spring" as const, bounce: 0.12, duration: 0.5 };const EASE_OUT = [0.23, 1, 0.32, 1] as const;
function toArray(v: string | string[] | undefined): string[] {  if (v === undefined || v === "") return [];  return Array.isArray(v) ? v : [v];}
/** Plus that folds into a minus: the vertical bar rotates flat. */function PlusMinus({ open, reduce }: { open: boolean; reduce: boolean }) {  return (    <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden>      <path d="M3 8h10" />      <motion.path        d="M8 3v10"        initial={false}        animate={{ rotate: open ? 90 : 0, opacity: open ? 0 : 1 }}        transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0.3, duration: 0.45 }}        style={{ originX: "50%", originY: "50%" }}      />    </svg>  );}
export function SpringAccordion(props: SpringAccordionProps) {  const { items, className } = props;  const type = props.type ?? "single";  const valueProp = props.value;  // Single and multiple share one implementation; the public types keep them apart.  const onValueChange = props.onValueChange as ((value: string | string[]) => void) | undefined;  const reduce = !!useReducedMotion();  const baseId = useId();  const groupRef = useRef<HTMLDivElement>(null);  const isControlled = valueProp !== undefined;  const [uncontrolled, setUncontrolled] = useState<string[]>(() => toArray(props.defaultValue));  const open = isControlled ? toArray(valueProp) : uncontrolled;
  const setOpen = useCallback(    (next: string[]) => {      if (!isControlled) setUncontrolled(next);      onValueChange?.(type === "single" ? (next[0] ?? "") : next);    },    [isControlled, onValueChange, type],  );
  const toggle = (itemValue: string) => {    const isOpen = open.includes(itemValue);    if (type === "single") setOpen(isOpen ? [] : [itemValue]);    else setOpen(isOpen ? open.filter((v) => v !== itemValue) : [...open, itemValue]);  };
  // WAI-ARIA accordion keys: Up/Down move between headers, Home/End jump to ends.  const onHeaderKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {    const headers = Array.from(      groupRef.current?.querySelectorAll<HTMLButtonElement>("[data-accordion-trigger]:not(:disabled)") ?? [],    );    const index = headers.indexOf(event.currentTarget);    if (index < 0) return;    const map: Record<string, number> = {      ArrowDown: (index + 1) % headers.length,      ArrowUp: (index - 1 + headers.length) % headers.length,      Home: 0,      End: headers.length - 1,    };    if (!(event.key in map)) return;    event.preventDefault();    headers[map[event.key]].focus();  };
  return (    <div ref={groupRef} className={cn("flex w-full flex-col gap-1.5", className)}>      {items.map((item, i) => {        const isOpen = open.includes(item.value);        const triggerId = `${baseId}-t-${item.value}`;        const panelId = `${baseId}-p-${item.value}`;        const prevOpen = i > 0 && open.includes(items[i - 1].value);        const nextOpen = i < items.length - 1 && open.includes(items[i + 1].value);
        return (          <motion.div            key={item.value}            layout={reduce ? false : "position"}            transition={HEIGHT_SPRING}            className={cn(              "relative overflow-hidden bg-(--color-paper) transition-[box-shadow,border-radius,margin] duration-300 ease-out",              isOpen                ? "my-1 rounded-[18px] shadow-[inset_0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.04),0_14px_32px_-14px_rgba(0,0,0,0.2)]"                : "rounded-[12px] shadow-[inset_0_0_0_1px_var(--color-rule)]",              !isOpen && !prevOpen && i > 0 && "rounded-t-[8px]",              !isOpen && !nextOpen && i < items.length - 1 && "rounded-b-[8px]",              isOpen && i === 0 && "mt-0",              isOpen && i === items.length - 1 && "mb-0",            )}          >            <button              type="button"              id={triggerId}              data-accordion-trigger              aria-expanded={isOpen}              aria-controls={panelId}              disabled={item.disabled}              onClick={() => toggle(item.value)}              onKeyDown={onHeaderKeyDown}              className={cn(                "group flex w-full items-center gap-3 px-4 py-3.5 text-left outline-none select-none",                "transition-colors duration-150 hover:bg-(--color-paper-2)/60",                "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-inset",                "disabled:cursor-not-allowed disabled:opacity-40",              )}            >              {item.icon ? (                <span                  aria-hidden                  className={cn(                    "flex size-8 shrink-0 items-center justify-center rounded-[10px] transition-colors duration-300 [&_svg]:size-4 [&_svg]:stroke-[1.75]",                    isOpen ? "bg-(--color-ink) text-(--color-paper)" : "bg-(--color-paper-2) text-(--color-ink-2)",                  )}                >                  {item.icon}                </span>              ) : null}              <span className="min-w-0 flex-1">                <span className="block text-[14px] leading-snug font-medium text-balance text-(--color-ink)">{item.title}</span>                {item.subtitle ? (                  <span className="mt-0.5 block text-[12.5px] text-(--color-neutral)">{item.subtitle}</span>                ) : null}              </span>              <span                aria-hidden                className={cn(                  "flex size-7 shrink-0 items-center justify-center rounded-full transition-[background-color,color] duration-300",                  isOpen                    ? "bg-(--color-ink) text-(--color-paper)"                    : "bg-(--color-paper-2) text-(--color-ink-2) group-hover:text-(--color-ink)",                )}              >                <PlusMinus open={isOpen} reduce={reduce} />              </span>            </button>
            <AnimatePresence initial={false}>              {isOpen ? (                <motion.div                  key="panel"                  id={panelId}                  role="region"                  aria-labelledby={triggerId}                  initial={{ height: 0 }}                  animate={{ height: "auto" }}                  exit={{ height: 0 }}                  transition={reduce ? { duration: 0 } : HEIGHT_SPRING}                  className="overflow-hidden"                >                  <motion.div                    initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: "blur(4px)" }}                    animate={{ opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: 0.35, ease: EASE_OUT, delay: 0.05 } }}                    exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: "blur(4px)", transition: { duration: 0.15 } }}                    className={cn(                      "pr-4 pb-4 text-[13.5px] leading-relaxed text-pretty text-(--color-ink-2)",                      item.icon ? "pl-[3.75rem]" : "pl-4",                    )}                  >                    {item.children}                  </motion.div>                </motion.div>              ) : null}            </AnimatePresence>          </motion.div>        );      })}    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { SpringAccordion } from "@/components/ui/spring-accordion";import { Waves } from "lucide-react";
<SpringAccordion  type="single"  defaultValue="springs"  items={[    {      value: "springs",      icon: <Waves />,      title: "Why springs instead of ease curves?",      subtitle: "Interruptible motion",      children: "Springs retarget from the live value when you interrupt them.",    },  ]}/>

Props

PropTypeRequiredDefaultDescription
itemsSpringAccordionItem[]Yes—Rows with value, title, children, and optional subtitle, icon and disabled.
type"single" | "multiple"No"single"One open row at a time, or any number.
value / defaultValuestring | string[]No—Controlled or initial open row(s): a string for single, an array for multiple.
onValueChange(value: string | string[]) => voidNo—Fires when a row opens or closes. Single mode passes an empty string when everything closes.
classNamestringNo—Classes for the stack.

Best Practices

  1. Use it for occasional expands (FAQs, settings, details), not lists people open many times a day.
  2. Keep titles to one line and use subtitle for context, so closed rows stay scannable.
  3. Closed panels are removed from the page, so put anything that must be searchable with find-in-page outside them.
  4. Reduced motion opens rows instantly with a short fade.