"use client"; import { motion, useReducedMotion, type Transition, } from "motion/react"; import { ChevronDown } from "lucide-react"; import { useCallback, useId, useLayoutEffect, useRef, useState, type ReactNode, } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type BouncyAccordionItem = { id: string; title: ReactNode; description?: ReactNode; icon?: ReactNode; disabled?: boolean; }; export type BouncyAccordionClassNames = { root?: string; item?: string; trigger?: string; icon?: string; title?: string; chevron?: string; content?: string; description?: string; }; export interface BouncyAccordionProps { items: BouncyAccordionItem[]; value?: string | null; defaultValue?: string | null; onValueChange?: (value: string | null) => void; collapsible?: boolean; className?: string; classNames?: BouncyAccordionClassNames; } // Local springs keep the accordion's connected groups moving together while // avoiding scale projection on text-heavy row contents. // Gap spring: must not overshoot y — positive y overshoot drifts items below // their mt-3 resting point and briefly overlaps the next item. const ROW_TRANSITION: Transition = { type: "spring", duration: 0.55, bounce: 0.38, }; const CONTENT_OPEN_TRANSITION: Transition = { type: "spring", duration: 0.58, bounce: 0.32, }; const CONTENT_CLOSE_TRANSITION: Transition = { type: "spring", duration: 0.46, bounce: 0.26, }; const DESCRIPTION_TRANSITION: Transition = { duration: 0.18, ease: EASE_OUT, }; const CHEVRON_TRANSITION: Transition = { type: "spring", duration: 0.42, bounce: 0.28, }; function useControllableAccordionValue({ value, defaultValue, onValueChange, }: { value?: string | null; defaultValue?: string | null; onValueChange?: (value: string | null) => void; }) { const [internalValue, setInternalValue] = useState(defaultValue ?? null); const isControlled = value !== undefined; const currentValue = value ?? internalValue; const setValue = useCallback( (next: string | null) => { if (!isControlled) { setInternalValue(next); } onValueChange?.(next); }, [isControlled, onValueChange], ); return [currentValue, setValue] as const; } function BouncyAccordionRow({ item, open, startsGroup, endsGroup, separatedFromPrevious, contentId, triggerId, reduce, classNames, onToggle, }: { item: BouncyAccordionItem; open: boolean; startsGroup: boolean; endsGroup: boolean; separatedFromPrevious: boolean; contentId: string; triggerId: string; reduce: boolean | null; classNames?: BouncyAccordionClassNames; onToggle: () => void; }) { const contentRef = useRef(null); const [contentHeight, setContentHeight] = useState(0); useLayoutEffect(() => { const node = contentRef.current; if (!node) return; const updateHeight = () => { setContentHeight(node.offsetHeight); }; updateHeight(); const observer = new ResizeObserver(updateHeight); observer.observe(node); return () => { observer.disconnect(); }; }, []); return (
{item.description}
); } export function BouncyAccordion({ items, value, defaultValue = null, onValueChange, collapsible = true, className, classNames, }: BouncyAccordionProps) { const reduce = useReducedMotion(); const baseId = useId(); const [activeValue, setActiveValue] = useControllableAccordionValue({ value, defaultValue, onValueChange, }); const activeIndex = items.findIndex((item) => item.id === activeValue); const toggleItem = useCallback( (id: string) => { if (activeValue === id) { if (collapsible) { setActiveValue(null); } return; } setActiveValue(id); }, [activeValue, collapsible, setActiveValue], ); return (
{items.map((item, index) => { const open = activeValue === item.id; const previousIsOpen = activeIndex === index - 1; const nextIsOpen = activeIndex === index + 1; const startsGroup = open || index === 0 || previousIsOpen; const endsGroup = open || index === items.length - 1 || nextIsOpen; const separatedFromPrevious = index > 0 && (open || previousIsOpen); const contentId = `${baseId}-${item.id}-content`; const triggerId = `${baseId}-${item.id}-trigger`; return ( toggleItem(item.id)} /> ); })}
); }