"use client"; import { animate, motion, useMotionValue, useReducedMotion } from "motion/react"; import { type ReactNode, useCallback, useLayoutEffect, useRef, useState, } from "react"; import { cn } from "@/lib/utils"; export interface BounceSidebarItem { id: string; label: ReactNode; href?: string; icon?: ReactNode; disabled?: boolean; target?: "_blank" | "_self" | "_parent" | "_top"; rel?: string; } export interface BounceSidebarProps { items: BounceSidebarItem[]; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; ariaLabel?: string; className?: string; listClassName?: string; itemClassName?: string; indicatorClassName?: string; } const DOT_SIZE = 6; // A compact, lightly underdamped spring gives the dot a quick landing without // turning the sidebar into a playful toy. The sideways arc carries the bounce. const BOUNCE_SPRING = { type: "spring", stiffness: 280, damping: 18, mass: 0.3, } as const; function quadraticBezier(start: number, control: number, end: number, progress: number) { const remaining = 1 - progress; return ( remaining * remaining * start + 2 * remaining * progress * control + progress * progress * end ); } export function BounceSidebar({ items, value, defaultValue, onValueChange, ariaLabel = "Sidebar navigation", className, listClassName, itemClassName, indicatorClassName, }: BounceSidebarProps) { const reduce = useReducedMotion(); const [internalValue, setInternalValue] = useState( defaultValue ?? items[0]?.id ?? "", ); const requestedValue = value ?? internalValue; const selectedValue = items.some((item) => item.id === requestedValue) ? requestedValue : (items[0]?.id ?? ""); const selectedIndex = items.findIndex((item) => item.id === selectedValue); const listRef = useRef(null); const itemRefs = useRef(new Map()); const selectedValueRef = useRef(selectedValue); const previousIndexRef = useRef(selectedIndex); const hasPositionRef = useRef(false); const animationRef = useRef | null>(null); const x = useMotionValue(0); const y = useMotionValue(0); selectedValueRef.current = selectedValue; const snapIndicator = useCallback(() => { const selectedItem = itemRefs.current.get(selectedValueRef.current); if (!selectedItem) return; animationRef.current?.stop(); x.set(0); y.set( selectedItem.offsetTop + (selectedItem.offsetHeight - DOT_SIZE) / 2, ); hasPositionRef.current = true; }, [x, y]); const positionIndicator = useCallback( (shouldAnimate: boolean) => { const selectedItem = itemRefs.current.get(selectedValue); if (!selectedItem) return; const destinationY = selectedItem.offsetTop + (selectedItem.offsetHeight - DOT_SIZE) / 2; animationRef.current?.stop(); if (!hasPositionRef.current || reduce || !shouldAnimate) { x.set(0); y.set(destinationY); hasPositionRef.current = true; previousIndexRef.current = selectedIndex; return; } const startY = y.get(); const distance = destinationY - startY; const travel = Math.abs(distance); const longJumpProgress = Math.min(1, Math.max(0, (travel - 48) / 120)); const controlX = -Math.min(40, Math.max(8, travel * 0.25)); const midpointY = (startY + destinationY) / 2; const controlY = destinationY + (midpointY - destinationY) * longJumpProgress; animationRef.current = animate(0, 1, { ...BOUNCE_SPRING, stiffness: BOUNCE_SPRING.stiffness - 60 * longJumpProgress, damping: BOUNCE_SPRING.damping + longJumpProgress, mass: BOUNCE_SPRING.mass + 0.15 * longJumpProgress, onUpdate: (progress) => { x.set(quadraticBezier(0, controlX, 0, progress)); y.set(quadraticBezier(startY, controlY, destinationY, progress)); }, onComplete: () => { x.set(0); y.set(destinationY); }, }); previousIndexRef.current = selectedIndex; }, [reduce, selectedIndex, selectedValue, x, y], ); useLayoutEffect(() => { const shouldAnimate = hasPositionRef.current && previousIndexRef.current !== selectedIndex; positionIndicator(shouldAnimate); }, [positionIndicator, selectedIndex]); useLayoutEffect(() => { const list = listRef.current; if (!list || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(snapIndicator); observer.observe(list); return () => observer.disconnect(); }, [snapIndicator]); useLayoutEffect( () => () => { animationRef.current?.stop(); }, [], ); const selectItem = (id: string) => { if (value === undefined) setInternalValue(id); onValueChange?.(id); }; return ( ); }