{"slug":"morphing-tabs","name":"Morphing Tabs","description":"Reorderable tabs whose selected item grows into a white content surface, with the active shape gliding as tabs move and a shared morph between rooms.","category":"blocks","source_url":"https://beui.dev/r/morphing-tabs/raw","detail_url":"https://beui.dev/r/morphing-tabs","raw_url":"https://beui.dev/r/morphing-tabs/raw","page_url":"https://beui.dev/components/blocks/morphing-tabs","markdown_url":"https://beui.dev/components/blocks/morphing-tabs.md","published_at":"2026-08-06","updated_at":"2026-08-06","dependencies":["clsx","lucide-react","motion","react","react-dom","tailwind-merge"],"internal":["@/components/motion/morphing-tabs","@/lib/ease","@/lib/touch","@/lib/utils"],"files":[{"path":"components/motion/morphing-tabs.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/blocks/morphing-tabs\n\nimport { X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate as animateValue,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { EASE_OUT, SPRING_GLIDE, SPRING_PRESS } from \"@/lib/ease\";\nimport { TOUCH_GESTURE_CLASS, capturePointer } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nexport type MorphingTabsItem = {\n  id: string;\n  label: string;\n  icon?: ReactNode;\n  content: ReactNode;\n  disabled?: boolean;\n};\n\nexport type MorphingTabsClassNames = {\n  root?: string;\n  rail?: string;\n  tab?: string;\n  activeTab?: string;\n  icon?: string;\n  label?: string;\n  close?: string;\n  content?: string;\n};\n\nexport interface MorphingTabsProps {\n  items: MorphingTabsItem[];\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (id: string | null) => void;\n  /** Called once after a pointer drag or keyboard reorder completes. */\n  onOrderChange?: (ids: string[]) => void;\n  /** Enables the close affordance on every tab when provided. */\n  onClose?: (id: string) => void;\n  ariaLabel?: string;\n  className?: string;\n  classNames?: MorphingTabsClassNames;\n}\n\ntype DragSession = {\n  id: string;\n  pointerId: number;\n  originX: number;\n  startLeft: number;\n  startIndex: number;\n  targetIndex: number;\n  moved: boolean;\n  finishing: boolean;\n  startOrder: string[];\n  slotLefts: number[];\n};\n\ntype SpringTabProps = {\n  id: string;\n  targetLeft: number;\n  dragging: boolean;\n  dragLeft: MotionValue<number>;\n  surfaceLeft: MotionValue<number>;\n  reduce: boolean;\n  active: boolean;\n  anyDragging: boolean;\n  surfaceHost: HTMLDivElement | null;\n  surfaceWidth: number;\n  tabWidth: number;\n  surfaceClassName?: string;\n  zIndex: number;\n  className: string;\n  children: ReactNode;\n  registerPosition: (id: string, position: MotionValue<number> | null) => void;\n  onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;\n  onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;\n  onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;\n  onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void;\n  onLostPointerCapture: (event: ReactPointerEvent<HTMLDivElement>) => void;\n};\n\nconst DRAG_THRESHOLD = 5;\n// The design width of a tab, and the narrowest it may shrink to before the\n// label stops reading. See `tabWidth` in MorphingTabs.\nconst MAX_TAB_WIDTH = 176;\nconst MIN_TAB_WIDTH = 96;\nconst TAB_HEIGHT = 56;\nconst TAB_TOP = 24;\nconst TAB_RADIUS = 24;\nconst RAIL_HEIGHT = 80;\nconst SURFACE_INSET = 16;\nconst LIQUID_JOIN = 24;\nconst PANEL_RADIUS = 28;\n\nfunction sameOrder(a: string[], b: string[]) {\n  return a.length === b.length && a.every((id, index) => id === b[index]);\n}\n\nfunction safeId(value: string) {\n  return value.replace(/[^a-zA-Z0-9_-]/g, \"-\");\n}\n\nfunction moveItem(order: string[], from: number, to: number) {\n  if (from === to) return order.slice();\n  const next = order.slice();\n  const [item] = next.splice(from, 1);\n  next.splice(to, 0, item);\n  return next;\n}\n\nfunction liquidTabPath(\n  tabLeft: number,\n  surfaceWidth: number,\n  tabWidth: number,\n) {\n  const panelLeft = SURFACE_INSET;\n  const panelRight = surfaceWidth - SURFACE_INSET;\n  const left = Math.max(\n    panelLeft,\n    Math.min(panelRight - tabWidth, tabLeft),\n  );\n  const right = left + tabWidth;\n  const top = RAIL_HEIGHT - TAB_HEIGHT;\n  const bottom = RAIL_HEIGHT;\n  const leftJoin = Math.max(panelLeft, left - LIQUID_JOIN);\n  const rightJoin = Math.min(panelRight, right + LIQUID_JOIN);\n  const leftDepth = Math.min(LIQUID_JOIN, left - leftJoin);\n  const rightDepth = Math.min(LIQUID_JOIN, rightJoin - right);\n  const leftControl = leftDepth * 0.55;\n  const rightControl = rightDepth * 0.55;\n  const leftPanelRadius = Math.min(PANEL_RADIUS, leftJoin - panelLeft);\n  const rightPanelRadius = Math.min(PANEL_RADIUS, panelRight - rightJoin);\n\n  return [\n    `M${panelLeft} ${bottom + PANEL_RADIUS}`,\n    `V${bottom + leftPanelRadius}`,\n    `Q${panelLeft} ${bottom} ${panelLeft + leftPanelRadius} ${bottom}`,\n    `H${leftJoin}`,\n    `C${leftJoin + leftControl} ${bottom} ${left} ${bottom - leftDepth + leftControl} ${left} ${bottom - leftDepth}`,\n    `V${top + TAB_RADIUS}`,\n    `Q${left} ${top} ${left + TAB_RADIUS} ${top}`,\n    `H${right - TAB_RADIUS}`,\n    `Q${right} ${top} ${right} ${top + TAB_RADIUS}`,\n    `V${bottom - rightDepth}`,\n    `C${right} ${bottom - rightDepth + rightControl} ${rightJoin - rightControl} ${bottom} ${rightJoin} ${bottom}`,\n    `H${panelRight - rightPanelRadius}`,\n    `Q${panelRight} ${bottom} ${panelRight} ${bottom + rightPanelRadius}`,\n    `V${bottom + PANEL_RADIUS}`,\n    \"Z\",\n  ].join(\" \");\n}\n\nfunction SpringTab({\n  id,\n  targetLeft,\n  dragging,\n  dragLeft,\n  surfaceLeft,\n  reduce,\n  active,\n  anyDragging,\n  surfaceHost,\n  surfaceWidth,\n  tabWidth,\n  surfaceClassName,\n  zIndex,\n  className,\n  children,\n  registerPosition,\n  onPointerDown,\n  onPointerMove,\n  onPointerUp,\n  onPointerCancel,\n  onLostPointerCapture,\n}: SpringTabProps) {\n  const target = useMotionValue(targetLeft);\n  const position = useSpring(target, SPRING_GLIDE);\n  const settledTransform = useTransform(\n    reduce ? target : position,\n    (left) => `translate3d(${left}px, 0, 0)`,\n  );\n  const draggedTransform = useTransform(\n    dragLeft,\n    (left) => `translate3d(${left}px, 0, 0)`,\n  );\n\n  useLayoutEffect(() => {\n    target.set(targetLeft);\n    if (reduce) position.jump(targetLeft);\n  }, [position, reduce, target, targetLeft]);\n\n  useLayoutEffect(() => {\n    registerPosition(id, position);\n    return () => registerPosition(id, null);\n  }, [id, position, registerPosition]);\n\n  const liquidDriver = anyDragging\n    ? dragging\n      ? dragLeft\n      : position\n    : surfaceLeft;\n\n  return (\n    <>\n      {active && surfaceHost && surfaceWidth > SURFACE_INSET * 2\n        ? createPortal(\n            <svg\n              aria-hidden=\"true\"\n              focusable=\"false\"\n              viewBox={`0 0 ${surfaceWidth} ${RAIL_HEIGHT + PANEL_RADIUS}`}\n              preserveAspectRatio=\"none\"\n              className={cn(\n                \"pointer-events-none absolute inset-x-0 top-0 h-[108px] w-full text-[#fafaf8]\",\n                dragging ? \"z-20\" : \"z-0\",\n                surfaceClassName,\n              )}\n            >\n              <LiquidSurfacePath\n                key={\n                  anyDragging\n                    ? dragging\n                      ? \"dragged\"\n                      : \"displaced\"\n                    : \"idle\"\n                }\n                left={liquidDriver}\n                surfaceWidth={surfaceWidth}\n                tabWidth={tabWidth}\n              />\n            </svg>,\n            surfaceHost,\n          )\n        : null}\n      <motion.div\n        style={{\n          zIndex,\n          transform: dragging ? draggedTransform : settledTransform,\n        }}\n        className={className}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerCancel}\n        onLostPointerCapture={onLostPointerCapture}\n      >\n        {children}\n      </motion.div>\n    </>\n  );\n}\n\nfunction LiquidSurfacePath({\n  left,\n  surfaceWidth,\n  tabWidth,\n}: {\n  left: MotionValue<number>;\n  surfaceWidth: number;\n  tabWidth: number;\n}) {\n  const path = useTransform(left, (value) =>\n    liquidTabPath(value, surfaceWidth, tabWidth),\n  );\n  return <motion.path d={path} fill=\"currentColor\" />;\n}\n\nexport function MorphingTabs({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  onOrderChange,\n  onClose,\n  ariaLabel = \"Tabs\",\n  className,\n  classNames,\n}: MorphingTabsProps) {\n  const reduce = Boolean(useReducedMotion());\n  const uid = useId();\n  const itemIds = useMemo(() => items.map((item) => item.id), [items]);\n  const itemMap = useMemo(\n    () => new Map(items.map((item) => [item.id, item])),\n    [items],\n  );\n  const [order, setOrder] = useState(itemIds);\n  const orderRef = useRef(order);\n  orderRef.current = order;\n\n  const [internalValue, setInternalValue] = useState<string | null>(\n    defaultValue ?? itemIds[0] ?? null,\n  );\n  const controlled = value !== undefined;\n  const currentValue = controlled ? (value ?? null) : internalValue;\n\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const railRef = useRef<HTMLDivElement | null>(null);\n  const tabButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});\n  const tabPositionRefs = useRef<Record<string, MotionValue<number> | null>>(\n    {},\n  );\n  const dragRef = useRef<DragSession | null>(null);\n  const dragAnimationRef = useRef<ReturnType<typeof animateValue> | null>(null);\n  const surfaceAnimationRef = useRef<ReturnType<typeof animateValue> | null>(\n    null,\n  );\n  const [surfaceWidth, setSurfaceWidth] = useState(0);\n  const [tabGap, setTabGap] = useState(12);\n  const [draggingId, setDraggingId] = useState<string | null>(null);\n  const [dragTargetIndex, setDragTargetIndex] = useState(-1);\n  const dragLeft = useMotionValue(SURFACE_INSET);\n  const surfaceLeft = useMotionValue(SURFACE_INSET);\n\n  useEffect(() => {\n    setOrder((current) => {\n      const available = new Set(itemIds);\n      const retained = current.filter((id) => available.has(id));\n      const retainedSet = new Set(retained);\n      const added = itemIds.filter((id) => !retainedSet.has(id));\n      const next = [...retained, ...added];\n      return sameOrder(current, next) ? current : next;\n    });\n  }, [itemIds]);\n\n  const orderedItems = useMemo(\n    () =>\n      order.flatMap((id) => {\n        const item = itemMap.get(id);\n        return item ? [item] : [];\n      }),\n    [itemMap, order],\n  );\n\n  const firstEnabledItem =\n    orderedItems.find((item) => !item.disabled) ?? orderedItems[0] ?? null;\n  const activeItem =\n    currentValue && itemMap.has(currentValue)\n      ? itemMap.get(currentValue) ?? null\n      : firstEnabledItem;\n  const activeId = activeItem?.id ?? null;\n\n  // The rail cannot scroll: the liquid surface is one continuous shape spanning\n  // the panel, so its notch has to stay over the tab that cut it. Slots narrow\n  // to fit the panel instead, in that order of sacrifice:\n  //   1. the design width, down to a floor where a truncated label still reads;\n  //   2. the gap between slots, so the floor survives one panel narrower;\n  //   3. the floor itself — a cramped tab is still tappable, a clipped one is\n  //      not reachable at all.\n  // Every tier fits inside the panel by construction, so no tab is ever cut off\n  // and the notch never has to be clamped away from the tab that cut it.\n  const { tabWidth, slotGap } = useMemo(() => {\n    const count = order.length;\n    if (!surfaceWidth || count === 0) {\n      return { tabWidth: MAX_TAB_WIDTH, slotGap: tabGap };\n    }\n    const inner = surfaceWidth - SURFACE_INSET * 2;\n    const widthAt = (gap: number) =>\n      Math.floor((inner - gap * (count - 1)) / count);\n\n    if (widthAt(tabGap) >= MIN_TAB_WIDTH) {\n      return {\n        tabWidth: Math.min(MAX_TAB_WIDTH, widthAt(tabGap)),\n        slotGap: tabGap,\n      };\n    }\n    if (count > 1 && widthAt(0) >= MIN_TAB_WIDTH) {\n      const gap = Math.floor((inner - MIN_TAB_WIDTH * count) / (count - 1));\n      return { tabWidth: MIN_TAB_WIDTH, slotGap: Math.max(0, gap) };\n    }\n    return { tabWidth: Math.max(0, widthAt(0)), slotGap: 0 };\n  }, [order.length, surfaceWidth, tabGap]);\n\n  const slotLefts = useMemo(\n    () =>\n      order.map(\n        (_, index) => SURFACE_INSET + index * (tabWidth + slotGap),\n      ),\n    [order, slotGap, tabWidth],\n  );\n\n  const dragStartIndex = draggingId ? order.indexOf(draggingId) : -1;\n\n  const visualIndexFor = useCallback(\n    (index: number) => {\n      if (dragStartIndex < 0 || dragTargetIndex < 0) return index;\n      if (index === dragStartIndex) return dragTargetIndex;\n\n      if (\n        dragTargetIndex > dragStartIndex &&\n        index > dragStartIndex &&\n        index <= dragTargetIndex\n      ) {\n        return index - 1;\n      }\n      if (\n        dragTargetIndex < dragStartIndex &&\n        index >= dragTargetIndex &&\n        index < dragStartIndex\n      ) {\n        return index + 1;\n      }\n      return index;\n    },\n    [dragStartIndex, dragTargetIndex],\n  );\n\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    const rail = railRef.current;\n    if (!root || !rail) return;\n\n    const measure = () => {\n      setSurfaceWidth(root.clientWidth);\n      const nextGap = Number.parseFloat(getComputedStyle(rail).columnGap);\n      if (Number.isFinite(nextGap)) setTabGap(nextGap);\n    };\n\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, []);\n\n  const setActive = useCallback(\n    (id: string | null) => {\n      if (id && itemMap.get(id)?.disabled) return;\n      if (!controlled) setInternalValue(id);\n      onValueChange?.(id);\n    },\n    [controlled, itemMap, onValueChange],\n  );\n\n  useEffect(() => {\n    if (currentValue && itemMap.has(currentValue)) return;\n    if (firstEnabledItem && firstEnabledItem.id !== currentValue) {\n      setActive(firstEnabledItem.id);\n    }\n  }, [currentValue, firstEnabledItem, itemMap, setActive]);\n\n  const activeOrderIndex = activeId ? order.indexOf(activeId) : -1;\n  const activeVisualIndex =\n    activeOrderIndex < 0 ? -1 : visualIndexFor(activeOrderIndex);\n\n  useLayoutEffect(() => {\n    if (\n      !activeId ||\n      activeVisualIndex < 0 ||\n      activeId === draggingId ||\n      !slotLefts[activeVisualIndex]\n    ) {\n      return;\n    }\n\n    surfaceAnimationRef.current?.stop();\n\n    if (draggingId) return;\n\n    surfaceAnimationRef.current = animateValue(\n      surfaceLeft,\n      slotLefts[activeVisualIndex],\n      reduce ? { duration: 0 } : SPRING_GLIDE,\n    );\n  }, [\n    activeId,\n    activeVisualIndex,\n    draggingId,\n    reduce,\n    slotLefts,\n    surfaceLeft,\n  ]);\n\n  const commitOrder = useCallback(\n    (next: string[], notify: boolean) => {\n      orderRef.current = next;\n      setOrder((current) => (sameOrder(current, next) ? current : next));\n      if (notify) onOrderChange?.(next);\n    },\n    [onOrderChange],\n  );\n\n  const registerPosition = useCallback(\n    (id: string, position: MotionValue<number> | null) => {\n      tabPositionRefs.current[id] = position;\n    },\n    [],\n  );\n\n  const startDrag = useCallback(\n    (id: string, event: ReactPointerEvent<HTMLDivElement>) => {\n      if (\n        event.button !== 0 ||\n        itemMap.get(id)?.disabled ||\n        dragRef.current\n      ) {\n        return;\n      }\n\n      const startIndex = orderRef.current.indexOf(id);\n      if (startIndex < 0) return;\n      const capturedSlots = orderRef.current.map(\n        (_, index) => SURFACE_INSET + index * (tabWidth + slotGap),\n      );\n      const startLeft = capturedSlots[startIndex];\n\n      dragAnimationRef.current?.stop();\n      dragAnimationRef.current = null;\n      dragLeft.set(startLeft);\n      dragRef.current = {\n        id,\n        pointerId: event.pointerId,\n        originX: event.clientX,\n        startLeft,\n        startIndex,\n        targetIndex: startIndex,\n        moved: false,\n        finishing: false,\n        startOrder: orderRef.current.slice(),\n        slotLefts: capturedSlots,\n      };\n    },\n    [dragLeft, itemMap, slotGap, tabWidth],\n  );\n\n  const moveDrag = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>) => {\n      const drag = dragRef.current;\n      if (!drag || drag.finishing || drag.pointerId !== event.pointerId) return;\n\n      const delta = event.clientX - drag.originX;\n      if (!drag.moved && Math.abs(delta) < DRAG_THRESHOLD) return;\n      event.preventDefault();\n\n      if (!drag.moved) {\n        drag.moved = true;\n        capturePointer(event.currentTarget, event.pointerId);\n        if (drag.id === activeId) {\n          surfaceAnimationRef.current?.stop();\n          surfaceLeft.set(drag.startLeft);\n        }\n        setDraggingId(drag.id);\n        setDragTargetIndex(drag.startIndex);\n      }\n\n      const minLeft = drag.slotLefts[0];\n      const maxLeft = drag.slotLefts[drag.slotLefts.length - 1];\n      const visualLeft = Math.max(\n        minLeft,\n        Math.min(maxLeft, drag.startLeft + delta),\n      );\n      let targetIndex = drag.startIndex;\n\n      if (visualLeft >= drag.startLeft) {\n        for (\n          let index = drag.startIndex + 1;\n          index < drag.slotLefts.length;\n          index += 1\n        ) {\n          if (visualLeft + tabWidth / 2 >= drag.slotLefts[index]) {\n            targetIndex = index;\n          }\n        }\n      } else {\n        for (let index = drag.startIndex - 1; index >= 0; index -= 1) {\n          if (visualLeft <= drag.slotLefts[index] + tabWidth / 2) {\n            targetIndex = index;\n          }\n        }\n      }\n\n      dragLeft.set(visualLeft);\n      if (targetIndex !== drag.targetIndex) {\n        drag.targetIndex = targetIndex;\n        setDragTargetIndex(targetIndex);\n      }\n    },\n    [activeId, dragLeft, surfaceLeft, tabWidth],\n  );\n\n  const finishDrag = useCallback(\n    (pointerId: number) => {\n      const drag = dragRef.current;\n      if (!drag || drag.pointerId !== pointerId || drag.finishing) return;\n\n      if (!drag.moved) {\n        dragRef.current = null;\n        return;\n      }\n\n      drag.finishing = true;\n      const targetLeft = drag.slotLefts[drag.targetIndex];\n      const controls = animateValue(\n        dragLeft,\n        targetLeft,\n        reduce ? { duration: 0 } : SPRING_GLIDE,\n      );\n      dragAnimationRef.current = controls;\n\n      controls.then(async () => {\n        if (dragAnimationRef.current !== controls) return;\n        const next = moveItem(\n          drag.startOrder,\n          drag.startIndex,\n          drag.targetIndex,\n        );\n\n        if (!reduce) {\n          await new Promise<void>((resolve) => {\n            const startedAt = performance.now();\n            const check = () => {\n              const settled = next.every((id, index) => {\n                if (id === drag.id) return true;\n                const position = tabPositionRefs.current[id];\n                if (!position) return true;\n                return (\n                  Math.abs(position.get() - drag.slotLefts[index]) < 0.5 &&\n                  Math.abs(position.getVelocity()) < 10\n                );\n              });\n\n              if (settled || performance.now() - startedAt > 500) {\n                resolve();\n                return;\n              }\n              requestAnimationFrame(check);\n            };\n            check();\n          });\n        }\n\n        if (dragAnimationRef.current !== controls) return;\n        if (drag.id === activeId) {\n          surfaceLeft.set(targetLeft);\n        } else if (activeId) {\n          const activePosition = tabPositionRefs.current[activeId];\n          if (activePosition) surfaceLeft.set(activePosition.get());\n        }\n        tabPositionRefs.current[drag.id]?.jump(targetLeft);\n        dragAnimationRef.current = null;\n        dragRef.current = null;\n        commitOrder(next, !sameOrder(drag.startOrder, next));\n        setDraggingId(null);\n        setDragTargetIndex(-1);\n      });\n    },\n    [activeId, commitOrder, dragLeft, reduce, surfaceLeft],\n  );\n\n  useEffect(() => {\n    const finishFromWindow = (event: PointerEvent) => {\n      finishDrag(event.pointerId);\n    };\n    window.addEventListener(\"pointerup\", finishFromWindow, true);\n    window.addEventListener(\"pointercancel\", finishFromWindow, true);\n    return () => {\n      window.removeEventListener(\"pointerup\", finishFromWindow, true);\n      window.removeEventListener(\"pointercancel\", finishFromWindow, true);\n    };\n  }, [finishDrag]);\n\n  const moveBy = useCallback(\n    (id: string, direction: -1 | 1) => {\n      const current = orderRef.current;\n      const index = current.indexOf(id);\n      const nextIndex = index + direction;\n      if (\n        index < 0 ||\n        nextIndex < 0 ||\n        nextIndex >= current.length ||\n        itemMap.get(id)?.disabled\n      ) {\n        return;\n      }\n      commitOrder(moveItem(current, index, nextIndex), true);\n    },\n    [commitOrder, itemMap],\n  );\n\n  const handleTabKeyDown = useCallback(\n    (id: string, event: React.KeyboardEvent<HTMLButtonElement>) => {\n      const index = orderRef.current.indexOf(id);\n      if (index < 0) return;\n\n      if (\n        event.altKey &&\n        (event.key === \"ArrowLeft\" || event.key === \"ArrowRight\")\n      ) {\n        event.preventDefault();\n        moveBy(id, event.key === \"ArrowLeft\" ? -1 : 1);\n        return;\n      }\n      if (event.key !== \"ArrowLeft\" && event.key !== \"ArrowRight\") return;\n\n      event.preventDefault();\n      const direction = event.key === \"ArrowLeft\" ? -1 : 1;\n      const nextIndex =\n        (index + direction + orderRef.current.length) % orderRef.current.length;\n      const nextId = orderRef.current[nextIndex];\n      setActive(nextId);\n      requestAnimationFrame(() => tabButtonRefs.current[nextId]?.focus());\n    },\n    [moveBy, setActive],\n  );\n\n  if (!orderedItems.length) return null;\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\n        \"relative isolate min-w-0 overflow-hidden rounded-[2rem] bg-[#292929] text-white\",\n        classNames?.root,\n        className,\n      )}\n    >\n      <div className=\"relative h-20\">\n        <div\n          ref={railRef}\n          role=\"tablist\"\n          aria-label={ariaLabel}\n          aria-orientation=\"horizontal\"\n          className={cn(\n            \"relative z-30 flex h-full gap-3 md:gap-4\",\n            classNames?.rail,\n          )}\n        >\n          {orderedItems.map((item, index) => {\n            const isActive = item.id === activeId;\n            const isDragging = item.id === draggingId;\n            const visualIndex = visualIndexFor(index);\n            const targetLeft = slotLefts[visualIndex] ?? SURFACE_INSET;\n            const tabId = `${uid}-tab-${safeId(item.id)}`;\n\n            return (\n              <SpringTab\n                key={item.id}\n                id={item.id}\n                targetLeft={targetLeft}\n                dragging={isDragging}\n                dragLeft={dragLeft}\n                surfaceLeft={surfaceLeft}\n                reduce={reduce}\n                active={isActive}\n                anyDragging={Boolean(draggingId)}\n                surfaceHost={rootRef.current}\n                surfaceWidth={surfaceWidth}\n                tabWidth={tabWidth}\n                surfaceClassName={classNames?.activeTab}\n                zIndex={isDragging ? 30 : isActive ? 20 : 1}\n                className={cn(\n                  // The drag is ours end to end, so iPadOS must not answer the\n                  // press with its callout or a native drag of the label.\n                  \"group absolute left-0 top-0 flex touch-pan-y items-stretch\",\n                  TOUCH_GESTURE_CLASS,\n                  item.disabled && \"cursor-not-allowed\",\n                  isDragging ? \"cursor-grabbing\" : \"cursor-grab\",\n                )}\n                registerPosition={registerPosition}\n                onPointerDown={(event) => startDrag(item.id, event)}\n                onPointerMove={moveDrag}\n                onPointerUp={(event) => finishDrag(event.pointerId)}\n                onPointerCancel={(event) => finishDrag(event.pointerId)}\n                // A touch is implicitly captured by whatever it landed on —\n                // here the label inside the tab — so the moment the drag takes\n                // the capture for the tab itself, that child *loses* it, and\n                // the notification bubbles straight back up to this handler.\n                // Ending the drag on it kills the gesture on the frame it\n                // starts. Only the tab losing its own capture means the\n                // platform took the pointer away. A mouse has no implicit\n                // capture, which is why this only ever bit on a real finger.\n                onLostPointerCapture={(event) => {\n                  if (event.target !== event.currentTarget) return;\n                  finishDrag(event.pointerId);\n                }}\n              >\n                <div\n                  style={{\n                    width: tabWidth,\n                    height: TAB_HEIGHT,\n                    marginTop: TAB_TOP,\n                  }}\n                  className=\"relative flex items-stretch\"\n                >\n                  {!isActive ? (\n                    <span\n                      aria-hidden\n                      className={cn(\n                        \"absolute inset-x-0 bottom-2 top-0 rounded-[1.25rem] transition-colors duration-200\",\n                        isDragging\n                          ? \"bg-[#3a3a3a]\"\n                          : \"bg-transparent group-hover:bg-white/[0.06]\",\n                      )}\n                    />\n                  ) : null}\n\n                  <button\n                    ref={(node) => {\n                      tabButtonRefs.current[item.id] = node;\n                    }}\n                    id={tabId}\n                    type=\"button\"\n                    role=\"tab\"\n                    aria-selected={isActive}\n                    aria-controls={`${uid}-panel`}\n                    aria-disabled={item.disabled || undefined}\n                    tabIndex={isActive ? 0 : -1}\n                    disabled={item.disabled}\n                    onClick={() => {\n                      const drag = dragRef.current;\n                      if (drag?.id === item.id && drag.moved) return;\n                      setActive(item.id);\n                    }}\n                    onKeyDown={(event) => handleTabKeyDown(item.id, event)}\n                    className={cn(\n                      \"group relative z-10 flex h-full w-full min-w-0 items-center gap-2 overflow-hidden rounded-t-[1.5rem] px-3 text-left outline-none transition-colors\",\n                      isActive\n                        ? \"text-[#181818]\"\n                        : \"pb-2 text-white/70 hover:text-white\",\n                      classNames?.tab,\n                    )}\n                  >\n                    <span\n                      aria-hidden\n                      className={cn(\n                        \"pointer-events-none absolute inset-x-1 top-1 opacity-0 transition-opacity group-focus-visible:opacity-100\",\n                        isActive\n                          ? \"bottom-0 rounded-t-[1.25rem] border-x-2 border-t-2 border-black/20\"\n                          : \"bottom-2 rounded-[1rem] border-2 border-white/60\",\n                      )}\n                    />\n                    {item.icon ? (\n                      <span\n                        aria-hidden\n                        className={cn(\n                          \"grid size-8 shrink-0 place-items-center\",\n                          classNames?.icon,\n                        )}\n                      >\n                        {item.icon}\n                      </span>\n                    ) : null}\n                    <span\n                      className={cn(\n                        \"min-w-0 truncate whitespace-nowrap text-base font-medium leading-none tracking-[-0.025em]\",\n                        classNames?.label,\n                      )}\n                    >\n                      {item.label}\n                    </span>\n                  </button>\n\n                  {onClose ? (\n                    <button\n                      type=\"button\"\n                      aria-label={`Close ${item.label}`}\n                      onPointerDown={(event) => event.stopPropagation()}\n                      onClick={(event) => {\n                        event.stopPropagation();\n                        onClose(item.id);\n                      }}\n                      className={cn(\n                        \"absolute right-2 top-1/2 z-20 grid size-6 -translate-y-1/2 place-items-center rounded-full text-[#9aa0a8] transition-colors hover:bg-black/[0.06] hover:text-[#4b5563] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current\",\n                        !isActive &&\n                          \"top-[calc(50%-4px)] text-white/45 hover:bg-white/[0.08] hover:text-white/80\",\n                        classNames?.close,\n                      )}\n                    >\n                      <X aria-hidden className=\"size-3.5 stroke-[1.5]\" />\n                    </button>\n                  ) : null}\n                </div>\n              </SpringTab>\n            );\n          })}\n        </div>\n      </div>\n\n      <div\n        id={`${uid}-panel`}\n        role=\"tabpanel\"\n        aria-labelledby={`${uid}-tab-${safeId(activeId ?? \"empty\")}`}\n        className={cn(\n          \"relative z-20 mx-4 min-h-64 overflow-hidden rounded-[1.75rem] bg-[#fafaf8] text-[#181818]\",\n          classNames?.content,\n        )}\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {activeItem ? (\n            <motion.div\n              key={activeItem.id}\n              initial={\n                reduce\n                  ? { opacity: 0 }\n                  : { opacity: 0, y: 8, filter: \"blur(6px)\" }\n              }\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={\n                reduce\n                  ? {\n                      opacity: 0,\n                      transition: { duration: 0.08, ease: EASE_OUT },\n                    }\n                  : {\n                      opacity: 0,\n                      y: -5,\n                      filter: \"blur(5px)\",\n                      transition: { duration: 0.12, ease: EASE_OUT },\n                    }\n              }\n              transition={\n                reduce\n                  ? { duration: 0.12, ease: EASE_OUT }\n                  : SPRING_PRESS\n              }\n              className=\"min-h-64\"\n            >\n              {activeItem.content}\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/touch.ts","type":"util","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/previews/blocks/morphing-tabs.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { useMemo, useState } from \"react\";\nimport { MorphingTabs, type MorphingTabsItem } from \"@/components/motion/morphing-tabs\";\n\nconst ROOM_CONTENT: Record<string, { eyebrow: string; title: string; detail: string; accent: string }> = {\n  \"room-2\": {\n    eyebrow: \"quiet focus\",\n    title: \"Room 2\",\n    detail: \"A small space for the work that needs a little more air around it.\",\n    accent: \"#db5b2f\",\n  },\n  general: {\n    eyebrow: \"shared space\",\n    title: \"General\",\n    detail: \"The common room for notes, links and the ideas that are still finding their shape.\",\n    accent: \"#1bb273\",\n  },\n  archive: {\n    eyebrow: \"kept close\",\n    title: \"Archive\",\n    detail: \"Past rooms stay available without competing with the conversations in motion.\",\n    accent: \"#7a6de2\",\n  },\n};\n\nfunction RoomPanel({ id }: { id: string }) {\n  const room = ROOM_CONTENT[id];\n\n  return (\n    <div className=\"relative min-h-64 overflow-hidden bg-[radial-gradient(circle_at_1px_1px,#dfe2e3_1px,transparent_1.5px)] bg-[size:4.8rem_4.8rem] px-7 py-8 md:px-12 md:py-10\">\n      <div className=\"relative max-w-xl\">\n        <p className=\"text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-black/40\">\n          {room.eyebrow}\n        </p>\n        <h3 className=\"mt-3 text-3xl font-light tracking-[-0.055em] text-[#151515] md:text-5xl\">\n          {room.title}\n        </h3>\n        <p className=\"mt-4 max-w-md text-sm leading-6 text-black/55 md:text-base\">\n          {room.detail}\n        </p>\n        <div className=\"mt-8 flex items-center gap-3 text-xs font-medium text-black/50\">\n          <span className=\"size-2 rounded-full\" style={{ backgroundColor: room.accent }} />\n          drag any room to reorder\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function MorphingTabsPreview() {\n  const initialItems = useMemo<MorphingTabsItem[]>(\n    () =>\n      Object.keys(ROOM_CONTENT).map((id) => ({\n        id,\n        label: ROOM_CONTENT[id].title,\n        content: <RoomPanel id={id} />,\n      })),\n    [],\n  );\n  const [items, setItems] = useState(initialItems);\n  const [value, setValue] = useState<string | null>(\"room-2\");\n\n  return (\n    <div className=\"flex w-full min-w-0 items-center justify-center bg-[#242424] p-3 md:p-8\">\n      <MorphingTabs\n        items={items}\n        value={value}\n        onValueChange={setValue}\n        onOrderChange={(ids) => {\n          setItems((current) => {\n            const byId = new Map(current.map((item) => [item.id, item]));\n            return ids.flatMap((id) => {\n              const item = byId.get(id);\n              return item ? [item] : [];\n            });\n          });\n        }}\n        onClose={(id) => {\n          setItems((current) => {\n            const next = current.filter((item) => item.id !== id);\n            if (id === value) setValue(next[0]?.id ?? null);\n            return next;\n          });\n        }}\n        ariaLabel=\"Rooms\"\n        className=\"w-full max-w-5xl\"\n      />\n    </div>\n  );\n}\n"}]}