"use client"; import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; import { motion, useMotionValue, useReducedMotion, useSpring, useTransform, useVelocity } from "motion/react"; import { type ReactNode, useCallback, useId, useLayoutEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export interface DripTabItem { value: string; label: string; icon?: ReactNode; content: ReactNode; } export interface DripTabsProps { items: DripTabItem[]; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; className?: string; "aria-label"?: string; } const leadSpring = { stiffness: 480, damping: 36, mass: 0.7 }; const trailSpring = { stiffness: 200, damping: 25, mass: 1 }; const DROP = 0.55; export function DripTabs({ items, value: valueProp, defaultValue, onValueChange, className, "aria-label": ariaLabel = "Sections", }: DripTabsProps) { const filterId = `drip-tabs-${useId().replace(/:/g, "")}`; const reduce = useReducedMotion(); const [internal, setInternal] = useState(defaultValue ?? items[0]?.value); const value = valueProp ?? internal; const listRef = useRef(null); const tabRefs = useRef(new Map()); const ready = useRef(false); const targetY = useMotionValue(0); const targetH = useMotionValue(0); const leadY = useSpring(targetY, leadSpring); const leadH = useSpring(targetH, leadSpring); const trailY = useSpring(targetY, trailSpring); const trailH = useSpring(targetH, trailSpring); const leadV = useVelocity(leadY); const trailV = useVelocity(trailY); const leadThin = useTransform(leadV, (v) => 1 - Math.min(Math.abs(v) / 6000, 0.08)); const trailThin = useTransform(trailV, (v) => 0.5 - Math.min(Math.abs(v) / 3000, 0.14)); const dropY = useTransform(() => trailY.get() + (trailH.get() * (1 - DROP)) / 2); const dropH = useTransform(trailH, (h) => h * DROP); const place = useCallback( (instant: boolean) => { const el = value ? tabRefs.current.get(value) : undefined; if (!el) return; targetY.set(el.offsetTop); targetH.set(el.offsetHeight); if (instant) { for (const [mv, v] of [ [leadY, el.offsetTop], [trailY, el.offsetTop], [leadH, el.offsetHeight], [trailH, el.offsetHeight], ] as const) mv.jump(v); } }, [value, targetY, targetH, leadY, trailY, leadH, trailH], ); useLayoutEffect(() => { place(!ready.current || !!reduce); ready.current = true; }, [place, reduce]); const placeRef = useRef(place); useLayoutEffect(() => { placeRef.current = place; }, [place]); useLayoutEffect(() => { const list = listRef.current; if (!list) return; let size = `${list.offsetWidth}x${list.offsetHeight}`; const ro = new ResizeObserver(() => { const next = `${list.offsetWidth}x${list.offsetHeight}`; if (next === size) return; size = next; placeRef.current(true); }); ro.observe(list); return () => ro.disconnect(); }, []); const change = (next: string) => { if (valueProp === undefined) setInternal(next); onValueChange?.(next); }; return ( change(v as string)} orientation="vertical" value={value} > {/* An opaque fill (never an alpha token) so the goo threshold leaves clean edges with no fringe. */} {items.map((item) => ( { if (el) tabRefs.current.set(item.value, el); else tabRefs.current.delete(item.value); }} value={item.value} > {item.icon} {item.label} ))} {items.map((item) => ( {item.content} ))} ); }