"use client"; import { X } from "lucide-react"; import { AnimatePresence, animate as animateValue, motion, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue, } from "motion/react"; import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { EASE_OUT, SPRING_GLIDE, SPRING_PRESS } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type MorphingTabsItem = { id: string; label: string; icon?: ReactNode; content: ReactNode; disabled?: boolean; }; export type MorphingTabsClassNames = { root?: string; rail?: string; tab?: string; activeTab?: string; icon?: string; label?: string; close?: string; content?: string; }; export interface MorphingTabsProps { items: MorphingTabsItem[]; value?: string | null; defaultValue?: string | null; onValueChange?: (id: string | null) => void; /** Called once after a pointer drag or keyboard reorder completes. */ onOrderChange?: (ids: string[]) => void; /** Enables the close affordance on every tab when provided. */ onClose?: (id: string) => void; ariaLabel?: string; className?: string; classNames?: MorphingTabsClassNames; } type DragSession = { id: string; pointerId: number; originX: number; startLeft: number; startIndex: number; targetIndex: number; moved: boolean; finishing: boolean; startOrder: string[]; slotLefts: number[]; }; type SpringTabProps = { id: string; targetLeft: number; dragging: boolean; dragLeft: MotionValue; surfaceLeft: MotionValue; reduce: boolean; active: boolean; anyDragging: boolean; surfaceHost: HTMLDivElement | null; surfaceWidth: number; surfaceClassName?: string; zIndex: number; className: string; children: ReactNode; registerPosition: (id: string, position: MotionValue | null) => void; onPointerDown: (event: ReactPointerEvent) => void; onPointerMove: (event: ReactPointerEvent) => void; onPointerUp: (event: ReactPointerEvent) => void; onPointerCancel: (event: ReactPointerEvent) => void; onLostPointerCapture: (event: ReactPointerEvent) => void; }; const DRAG_THRESHOLD = 5; const TAB_WIDTH = 176; const TAB_HEIGHT = 56; const TAB_TOP = 24; const TAB_RADIUS = 24; const RAIL_HEIGHT = 80; const SURFACE_INSET = 16; const LIQUID_JOIN = 24; const PANEL_RADIUS = 28; function sameOrder(a: string[], b: string[]) { return a.length === b.length && a.every((id, index) => id === b[index]); } function safeId(value: string) { return value.replace(/[^a-zA-Z0-9_-]/g, "-"); } function moveItem(order: string[], from: number, to: number) { if (from === to) return order.slice(); const next = order.slice(); const [item] = next.splice(from, 1); next.splice(to, 0, item); return next; } function liquidTabPath(tabLeft: number, surfaceWidth: number) { const panelLeft = SURFACE_INSET; const panelRight = surfaceWidth - SURFACE_INSET; const left = Math.max( panelLeft, Math.min(panelRight - TAB_WIDTH, tabLeft), ); const right = left + TAB_WIDTH; const top = RAIL_HEIGHT - TAB_HEIGHT; const bottom = RAIL_HEIGHT; const leftJoin = Math.max(panelLeft, left - LIQUID_JOIN); const rightJoin = Math.min(panelRight, right + LIQUID_JOIN); const leftDepth = Math.min(LIQUID_JOIN, left - leftJoin); const rightDepth = Math.min(LIQUID_JOIN, rightJoin - right); const leftControl = leftDepth * 0.55; const rightControl = rightDepth * 0.55; const leftPanelRadius = Math.min(PANEL_RADIUS, leftJoin - panelLeft); const rightPanelRadius = Math.min(PANEL_RADIUS, panelRight - rightJoin); return [ `M${panelLeft} ${bottom + PANEL_RADIUS}`, `V${bottom + leftPanelRadius}`, `Q${panelLeft} ${bottom} ${panelLeft + leftPanelRadius} ${bottom}`, `H${leftJoin}`, `C${leftJoin + leftControl} ${bottom} ${left} ${bottom - leftDepth + leftControl} ${left} ${bottom - leftDepth}`, `V${top + TAB_RADIUS}`, `Q${left} ${top} ${left + TAB_RADIUS} ${top}`, `H${right - TAB_RADIUS}`, `Q${right} ${top} ${right} ${top + TAB_RADIUS}`, `V${bottom - rightDepth}`, `C${right} ${bottom - rightDepth + rightControl} ${rightJoin - rightControl} ${bottom} ${rightJoin} ${bottom}`, `H${panelRight - rightPanelRadius}`, `Q${panelRight} ${bottom} ${panelRight} ${bottom + rightPanelRadius}`, `V${bottom + PANEL_RADIUS}`, "Z", ].join(" "); } function SpringTab({ id, targetLeft, dragging, dragLeft, surfaceLeft, reduce, active, anyDragging, surfaceHost, surfaceWidth, surfaceClassName, zIndex, className, children, registerPosition, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture, }: SpringTabProps) { const target = useMotionValue(targetLeft); const position = useSpring(target, SPRING_GLIDE); const settledTransform = useTransform( reduce ? target : position, (left) => `translate3d(${left}px, 0, 0)`, ); const draggedTransform = useTransform( dragLeft, (left) => `translate3d(${left}px, 0, 0)`, ); useLayoutEffect(() => { target.set(targetLeft); if (reduce) position.jump(targetLeft); }, [position, reduce, target, targetLeft]); useLayoutEffect(() => { registerPosition(id, position); return () => registerPosition(id, null); }, [id, position, registerPosition]); const liquidDriver = anyDragging ? dragging ? dragLeft : position : surfaceLeft; return ( <> {active && surfaceHost && surfaceWidth > SURFACE_INSET * 2 ? createPortal( , surfaceHost, ) : null} {children} ); } function LiquidSurfacePath({ left, surfaceWidth, }: { left: MotionValue; surfaceWidth: number; }) { const path = useTransform(left, (value) => liquidTabPath(value, surfaceWidth), ); return ; } export function MorphingTabs({ items, value, defaultValue, onValueChange, onOrderChange, onClose, ariaLabel = "Tabs", className, classNames, }: MorphingTabsProps) { const reduce = Boolean(useReducedMotion()); const uid = useId(); const itemIds = useMemo(() => items.map((item) => item.id), [items]); const itemMap = useMemo( () => new Map(items.map((item) => [item.id, item])), [items], ); const [order, setOrder] = useState(itemIds); const orderRef = useRef(order); orderRef.current = order; const [internalValue, setInternalValue] = useState( defaultValue ?? itemIds[0] ?? null, ); const controlled = value !== undefined; const currentValue = controlled ? (value ?? null) : internalValue; const rootRef = useRef(null); const railRef = useRef(null); const tabButtonRefs = useRef>({}); const tabPositionRefs = useRef | null>>( {}, ); const dragRef = useRef(null); const dragAnimationRef = useRef | null>(null); const surfaceAnimationRef = useRef | null>( null, ); const [surfaceWidth, setSurfaceWidth] = useState(0); const [tabGap, setTabGap] = useState(12); const [draggingId, setDraggingId] = useState(null); const [dragTargetIndex, setDragTargetIndex] = useState(-1); const dragLeft = useMotionValue(SURFACE_INSET); const surfaceLeft = useMotionValue(SURFACE_INSET); useEffect(() => { setOrder((current) => { const available = new Set(itemIds); const retained = current.filter((id) => available.has(id)); const retainedSet = new Set(retained); const added = itemIds.filter((id) => !retainedSet.has(id)); const next = [...retained, ...added]; return sameOrder(current, next) ? current : next; }); }, [itemIds]); const orderedItems = useMemo( () => order.flatMap((id) => { const item = itemMap.get(id); return item ? [item] : []; }), [itemMap, order], ); const firstEnabledItem = orderedItems.find((item) => !item.disabled) ?? orderedItems[0] ?? null; const activeItem = currentValue && itemMap.has(currentValue) ? itemMap.get(currentValue) ?? null : firstEnabledItem; const activeId = activeItem?.id ?? null; const slotLefts = useMemo( () => order.map( (_, index) => SURFACE_INSET + index * (TAB_WIDTH + tabGap), ), [order, tabGap], ); const dragStartIndex = draggingId ? order.indexOf(draggingId) : -1; const visualIndexFor = useCallback( (index: number) => { if (dragStartIndex < 0 || dragTargetIndex < 0) return index; if (index === dragStartIndex) return dragTargetIndex; if ( dragTargetIndex > dragStartIndex && index > dragStartIndex && index <= dragTargetIndex ) { return index - 1; } if ( dragTargetIndex < dragStartIndex && index >= dragTargetIndex && index < dragStartIndex ) { return index + 1; } return index; }, [dragStartIndex, dragTargetIndex], ); useLayoutEffect(() => { const root = rootRef.current; const rail = railRef.current; if (!root || !rail) return; const measure = () => { setSurfaceWidth(root.clientWidth); const nextGap = Number.parseFloat(getComputedStyle(rail).columnGap); if (Number.isFinite(nextGap)) setTabGap(nextGap); }; measure(); const observer = new ResizeObserver(measure); observer.observe(root); return () => observer.disconnect(); }, []); const setActive = useCallback( (id: string | null) => { if (id && itemMap.get(id)?.disabled) return; if (!controlled) setInternalValue(id); onValueChange?.(id); }, [controlled, itemMap, onValueChange], ); useEffect(() => { if (currentValue && itemMap.has(currentValue)) return; if (firstEnabledItem && firstEnabledItem.id !== currentValue) { setActive(firstEnabledItem.id); } }, [currentValue, firstEnabledItem, itemMap, setActive]); const activeOrderIndex = activeId ? order.indexOf(activeId) : -1; const activeVisualIndex = activeOrderIndex < 0 ? -1 : visualIndexFor(activeOrderIndex); useLayoutEffect(() => { if ( !activeId || activeVisualIndex < 0 || activeId === draggingId || !slotLefts[activeVisualIndex] ) { return; } surfaceAnimationRef.current?.stop(); if (draggingId) return; surfaceAnimationRef.current = animateValue( surfaceLeft, slotLefts[activeVisualIndex], reduce ? { duration: 0 } : SPRING_GLIDE, ); }, [ activeId, activeVisualIndex, draggingId, reduce, slotLefts, surfaceLeft, ]); const commitOrder = useCallback( (next: string[], notify: boolean) => { orderRef.current = next; setOrder((current) => (sameOrder(current, next) ? current : next)); if (notify) onOrderChange?.(next); }, [onOrderChange], ); const registerPosition = useCallback( (id: string, position: MotionValue | null) => { tabPositionRefs.current[id] = position; }, [], ); const startDrag = useCallback( (id: string, event: ReactPointerEvent) => { if ( event.button !== 0 || itemMap.get(id)?.disabled || dragRef.current ) { return; } const startIndex = orderRef.current.indexOf(id); if (startIndex < 0) return; const capturedSlots = orderRef.current.map( (_, index) => SURFACE_INSET + index * (TAB_WIDTH + tabGap), ); const startLeft = capturedSlots[startIndex]; dragAnimationRef.current?.stop(); dragAnimationRef.current = null; dragLeft.set(startLeft); dragRef.current = { id, pointerId: event.pointerId, originX: event.clientX, startLeft, startIndex, targetIndex: startIndex, moved: false, finishing: false, startOrder: orderRef.current.slice(), slotLefts: capturedSlots, }; }, [dragLeft, itemMap, tabGap], ); const moveDrag = useCallback( (event: ReactPointerEvent) => { const drag = dragRef.current; if (!drag || drag.finishing || drag.pointerId !== event.pointerId) return; const delta = event.clientX - drag.originX; if (!drag.moved && Math.abs(delta) < DRAG_THRESHOLD) return; event.preventDefault(); if (!drag.moved) { drag.moved = true; event.currentTarget.setPointerCapture(event.pointerId); if (drag.id === activeId) { surfaceAnimationRef.current?.stop(); surfaceLeft.set(drag.startLeft); } setDraggingId(drag.id); setDragTargetIndex(drag.startIndex); } const minLeft = drag.slotLefts[0]; const maxLeft = drag.slotLefts[drag.slotLefts.length - 1]; const visualLeft = Math.max( minLeft, Math.min(maxLeft, drag.startLeft + delta), ); let targetIndex = drag.startIndex; if (visualLeft >= drag.startLeft) { for ( let index = drag.startIndex + 1; index < drag.slotLefts.length; index += 1 ) { if (visualLeft + TAB_WIDTH / 2 >= drag.slotLefts[index]) { targetIndex = index; } } } else { for (let index = drag.startIndex - 1; index >= 0; index -= 1) { if (visualLeft <= drag.slotLefts[index] + TAB_WIDTH / 2) { targetIndex = index; } } } dragLeft.set(visualLeft); if (targetIndex !== drag.targetIndex) { drag.targetIndex = targetIndex; setDragTargetIndex(targetIndex); } }, [activeId, dragLeft, surfaceLeft], ); const finishDrag = useCallback( (pointerId: number) => { const drag = dragRef.current; if (!drag || drag.pointerId !== pointerId || drag.finishing) return; if (!drag.moved) { dragRef.current = null; return; } drag.finishing = true; const targetLeft = drag.slotLefts[drag.targetIndex]; const controls = animateValue( dragLeft, targetLeft, reduce ? { duration: 0 } : SPRING_GLIDE, ); dragAnimationRef.current = controls; controls.then(async () => { if (dragAnimationRef.current !== controls) return; const next = moveItem( drag.startOrder, drag.startIndex, drag.targetIndex, ); if (!reduce) { await new Promise((resolve) => { const startedAt = performance.now(); const check = () => { const settled = next.every((id, index) => { if (id === drag.id) return true; const position = tabPositionRefs.current[id]; if (!position) return true; return ( Math.abs(position.get() - drag.slotLefts[index]) < 0.5 && Math.abs(position.getVelocity()) < 10 ); }); if (settled || performance.now() - startedAt > 500) { resolve(); return; } requestAnimationFrame(check); }; check(); }); } if (dragAnimationRef.current !== controls) return; if (drag.id === activeId) { surfaceLeft.set(targetLeft); } else if (activeId) { const activePosition = tabPositionRefs.current[activeId]; if (activePosition) surfaceLeft.set(activePosition.get()); } tabPositionRefs.current[drag.id]?.jump(targetLeft); dragAnimationRef.current = null; dragRef.current = null; commitOrder(next, !sameOrder(drag.startOrder, next)); setDraggingId(null); setDragTargetIndex(-1); }); }, [activeId, commitOrder, dragLeft, reduce, surfaceLeft], ); useEffect(() => { const finishFromWindow = (event: PointerEvent) => { finishDrag(event.pointerId); }; window.addEventListener("pointerup", finishFromWindow, true); window.addEventListener("pointercancel", finishFromWindow, true); return () => { window.removeEventListener("pointerup", finishFromWindow, true); window.removeEventListener("pointercancel", finishFromWindow, true); }; }, [finishDrag]); const moveBy = useCallback( (id: string, direction: -1 | 1) => { const current = orderRef.current; const index = current.indexOf(id); const nextIndex = index + direction; if ( index < 0 || nextIndex < 0 || nextIndex >= current.length || itemMap.get(id)?.disabled ) { return; } commitOrder(moveItem(current, index, nextIndex), true); }, [commitOrder, itemMap], ); const handleTabKeyDown = useCallback( (id: string, event: React.KeyboardEvent) => { const index = orderRef.current.indexOf(id); if (index < 0) return; if ( event.altKey && (event.key === "ArrowLeft" || event.key === "ArrowRight") ) { event.preventDefault(); moveBy(id, event.key === "ArrowLeft" ? -1 : 1); return; } if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; event.preventDefault(); const direction = event.key === "ArrowLeft" ? -1 : 1; const nextIndex = (index + direction + orderRef.current.length) % orderRef.current.length; const nextId = orderRef.current[nextIndex]; setActive(nextId); requestAnimationFrame(() => tabButtonRefs.current[nextId]?.focus()); }, [moveBy, setActive], ); if (!orderedItems.length) return null; return (
{orderedItems.map((item, index) => { const isActive = item.id === activeId; const isDragging = item.id === draggingId; const visualIndex = visualIndexFor(index); const targetLeft = slotLefts[visualIndex] ?? SURFACE_INSET; const tabId = `${uid}-tab-${safeId(item.id)}`; return ( startDrag(item.id, event)} onPointerMove={moveDrag} onPointerUp={(event) => finishDrag(event.pointerId)} onPointerCancel={(event) => finishDrag(event.pointerId)} onLostPointerCapture={(event) => finishDrag(event.pointerId)} >
{!isActive ? ( ) : null} {onClose ? ( ) : null}
); })}
{activeItem ? ( {activeItem.content} ) : null}
); }