"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; } // Rows, cards and scale-corrected text share a transition so their edges // stay joined. A close or switch uses the faster non-overshooting spring; // opening from all-closed keeps the weighted bounce. const EXPAND_TRANSITION: Transition = { type: "spring", duration: 0.58, bounce: 0.32, }; const COLLAPSE_TRANSITION: Transition = { type: "spring", duration: 0.46, bounce: 0, }; 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 = isControlled ? 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, layoutTransition, layoutDependency, classNames, onToggle, }: { item: BouncyAccordionItem; open: boolean; startsGroup: boolean; endsGroup: boolean; separatedFromPrevious: boolean; contentId: string; triggerId: string; reduce: boolean | null; layoutTransition: Transition; layoutDependency: string | 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.icon ? ( {item.icon} ) : null} {item.title}
{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 previousValue = useRef(activeValue); const layoutTransition = activeValue === null || previousValue.current !== null ? COLLAPSE_TRANSITION : EXPAND_TRANSITION; useLayoutEffect(() => { previousValue.current = activeValue; }, [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)} /> ); })}
); }