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 { motion, useReducedMotion } from "motion/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;};
const SPRING = { type: "spring" as const, bounce: 0, duration: 0.35 };
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 reduce = useReducedMotion();  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="group">            <motion.button              type="button"              id={triggerId}              aria-expanded={isOpen}              aria-controls={panelId}              disabled={item.disabled}              onClick={() => !item.disabled && toggle(item.value)}              whileTap={                item.disabled || reduce ? undefined : { scale: 0.96 }              }              transition={{ duration: 0.1 }}              className={cn(                "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",              )}            >              <span className="text-balance">{item.title}</span>              <motion.span                animate={{ rotate: isOpen ? 180 : 0 }}                transition={reduce ? { duration: 0.12 } : SPRING}                className="flex size-5 shrink-0 items-center justify-center text-(--color-ink-muted)"                aria-hidden              >                <ChevronDown className="size-4 stroke-[1.5]" />              </motion.span>            </motion.button>
            <motion.div              id={panelId}              role="region"              aria-labelledby={triggerId}              initial={false}              animate={                isOpen                  ? {                      height: "auto",                      opacity: 1,                    }                  : {                      height: 0,                      opacity: 0,                    }              }              transition={                reduce                  ? { duration: 0.15, ease: "easeOut" }                  : SPRING              }              className="overflow-hidden"            >              <div className="px-4 pb-4 text-sm leading-relaxed text-(--color-ink-2)">                {item.children}              </div>            </motion.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.