Overview

FAQ / settings accordion that animates height and opacity with { bounce: 0, duration: 0.35 }. Rapid open/close retargets mid-flight — no locked keyframe sequence. Chevron rotation shares the same spring.


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, useState } from "react";import { ChevronDown } from "lucide-react";import { cn } from "@/lib/utils";
export type SpringAccordionItem = {  value: string;  title: string;  children: React.ReactNode;  disabled?: boolean;};
export type SpringAccordionProps = {  items: SpringAccordionItem[];  type?: "single" | "multiple";  value?: string | string[];  defaultValue?: string | string[];  onValueChange?: (value: string | string[]) => void;  className?: string;};
function toArray(v: string | string[] | undefined): string[] {  if (v === undefined) return [];  return Array.isArray(v) ? v : [v];}
export function SpringAccordion({  items,  type = "single",  value: valueProp,  defaultValue,  onValueChange,  className,}: SpringAccordionProps) {  const baseId = useId();  const isControlled = valueProp !== undefined;  const [uncontrolled, setUncontrolled] = useState<string[]>(() =>    toArray(defaultValue),  );  const open = isControlled ? toArray(valueProp) : uncontrolled;
  const setOpen = useCallback(    (next: string[]) => {      if (!isControlled) setUncontrolled(next);      if (type === "single") {        onValueChange?.(next[0] ?? "");      } else {        onValueChange?.(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],      );    }  };
  return (    <div      className={cn(        "w-full divide-y divide-(--color-rule) overflow-hidden",        "rounded-lg border border-(--color-rule) bg-(--color-paper)",        className,      )}    >      {items.map((item) => {        const isOpen = open.includes(item.value);        const triggerId = `${baseId}-t-${item.value}`;        const panelId = `${baseId}-p-${item.value}`;
        return (          <div            key={item.value}            className="t-acc group"            data-open={isOpen ? "true" : "false"}          >            <button              type="button"              id={triggerId}              className={cn(                "t-acc-head flex w-full items-center justify-between gap-3 px-4 py-3.5 text-left",                "text-sm font-medium text-(--color-ink)",                "outline-none select-none",                "transition-colors duration-(--dur-micro) ease-out",                "hover:bg-(--color-paper-2)",                "focus-visible:bg-(--color-paper-2)",                "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-(--color-focus)",                "disabled:pointer-events-none disabled:opacity-40",              )}              aria-expanded={isOpen}              aria-controls={panelId}              disabled={item.disabled}              onClick={() => !item.disabled && toggle(item.value)}            >              <span className="text-balance">{item.title}</span>              <span                className="t-acc-chevron flex size-5 shrink-0 items-center justify-center text-(--color-ink-muted)"                aria-hidden              >                <ChevronDown className="size-4 stroke-[1.5]" />              </span>            </button>
            <div              id={panelId}              role="region"              aria-labelledby={triggerId}              className="t-acc-panel"            >              <div className="t-acc-panel-inner px-4 pb-4 text-sm leading-relaxed text-(--color-ink-2)">                {item.children}              </div>            </div>          </div>        );      })}    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { SpringAccordion } from "@/components/ui/spring-accordion";
<SpringAccordion  type="single"  defaultValue="a"  items={[    {      value: "a",      title: "Why springs?",      children: "They retarget from the live value when interrupted.",    },  ]}/>

Props

PropTypeRequiredDefaultDescription
itemsSpringAccordionItem[]YesPanels with value, title, children, optional disabled.
type"single" | "multiple"No"single"One open panel vs many.
valuestring | string[]NoControlled open value(s).
defaultValuestring | string[]NoUncontrolled initial open value(s).
onValueChange(value: string | string[]) => voidNoFires when open state changes.
classNamestringNoOptional class names on the root.

Best Practices

  1. Use for occasional expands (FAQ, settings) — not high-frequency navigation.
  2. Keep bounce at zero; overshoot on an accordion feels wrong without a flick.
  3. Reduced motion collapses to a short opacity/height ease.
  4. Trigger press uses scale(0.98) — subtler than primary buttons.