{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "drag-and-drop-tabs", "type": "registry:ui", "title": "Drag And Drop Tabs", "description": "Reorderable tabs with spring physics on drop, staggered cascade, velocity-aware snap, and audio tick.", "dependencies": [ "motion", "@dnd-kit/core", "@dnd-kit/sortable" ], "files": [ { "path": "registry/ruixenui/drag-and-drop-tabs.tsx", "content": "\"use client\";\n\nimport { useState, useRef, useCallback, useEffect } from \"react\";\nimport { motion, useMotionValue, useSpring } from \"motion/react\";\nimport {\n DndContext,\n closestCenter,\n PointerSensor,\n useSensor,\n useSensors,\n} from \"@dnd-kit/core\";\nimport type {\n DragStartEvent,\n DragEndEvent,\n DragOverEvent,\n} from \"@dnd-kit/core\";\nimport {\n arrayMove,\n SortableContext,\n useSortable,\n horizontalListSortingStrategy,\n} from \"@dnd-kit/sortable\";\n\n/**\n * Drag & Drop Tabs — Rauno Freiberg craft.\n *\n * Reorderable tabs with spring physics on drop,\n * staggered cascade, velocity-aware snap, and audio tick\n * on drag start, reorder, and drop.\n */\n\n/* ── Audio singleton ── */\n\nlet _a: AudioContext | null = null;\nlet _b: AudioBuffer | null = null;\n\nfunction getCtx(): AudioContext {\n if (!_a)\n _a = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)();\n if (_a.state === \"suspended\") _a.resume();\n return _a;\n}\n\nfunction getBuf(ac: AudioContext): AudioBuffer {\n if (_b && _b.sampleRate === ac.sampleRate) return _b;\n const len = Math.floor(ac.sampleRate * 0.003);\n const buf = ac.createBuffer(1, len, ac.sampleRate);\n const ch = buf.getChannelData(0);\n for (let i = 0; i < len; i++)\n ch[i] = (Math.random() * 2 - 1) * (1 - i / len) ** 4;\n _b = buf;\n return buf;\n}\n\nfunction tick(ref: React.MutableRefObject) {\n const now = performance.now();\n if (now - ref.current < 25) return;\n ref.current = now;\n try {\n const ac = getCtx();\n const src = ac.createBufferSource();\n const g = ac.createGain();\n src.buffer = getBuf(ac);\n g.gain.value = 0.06;\n src.connect(g).connect(ac.destination);\n src.start();\n } catch {\n /* silent */\n }\n}\n\n/* ── Types ── */\n\nexport interface DragTabItem {\n value: string;\n label: string;\n content?: React.ReactNode;\n}\n\ninterface DragAndDropTabsProps {\n items?: DragTabItem[];\n defaultValue?: string;\n onChange?: (value: string) => void;\n sound?: boolean;\n className?: string;\n}\n\n/* ── CSS ── */\n\nconst CSS = `.ddt{--ddt-bg:rgba(255,255,255,.72);--ddt-border:rgba(0,0,0,.06);--ddt-shadow:0 0 0 .5px rgba(0,0,0,.04),0 2px 4px rgba(0,0,0,.04),0 8px 24px rgba(0,0,0,.06);--ddt-pill:rgba(0,0,0,.07);--ddt-text:rgba(0,0,0,.5);--ddt-text-active:rgba(0,0,0,.9);--ddt-drag-shadow:0 4px 16px rgba(0,0,0,.12);--ddt-content-bg:rgba(0,0,0,.02);--ddt-content-border:rgba(0,0,0,.06)}.dark .ddt,[data-theme=\"dark\"] .ddt{--ddt-bg:rgba(30,30,32,.82);--ddt-border:rgba(255,255,255,.06);--ddt-shadow:0 0 0 .5px rgba(255,255,255,.04),0 2px 4px rgba(0,0,0,.2),0 8px 24px rgba(0,0,0,.3);--ddt-pill:rgba(255,255,255,.08);--ddt-text:rgba(255,255,255,.45);--ddt-text-active:rgba(255,255,255,.9);--ddt-drag-shadow:0 4px 20px rgba(0,0,0,.4);--ddt-content-bg:rgba(255,255,255,.03);--ddt-content-border:rgba(255,255,255,.06)}`;\n\n/* ── Constants ── */\n\nconst SPRING = { type: \"spring\" as const, stiffness: 400, damping: 28 };\nconst CASCADE = { type: \"spring\" as const, stiffness: 350, damping: 30 };\n\n/* ── SortableTab ── */\n\nfunction SortableTab({\n id,\n label,\n isActive,\n onSelect,\n cascadeDelay,\n}: {\n id: string;\n label: string;\n isActive: boolean;\n onSelect: () => void;\n cascadeDelay: number;\n}) {\n const { attributes, listeners, setNodeRef, transform, isDragging } =\n useSortable({ id });\n\n const tx = transform?.x ?? 0;\n const ty = transform?.y ?? 0;\n\n return (\n \n {label}\n \n );\n}\n\n/* ── Component ── */\n\nexport function DragAndDropTabs({\n items: defaultItems = [\n { value: \"tab1\", label: \"Tab 1\", content: \"Content for Tab 1.\" },\n { value: \"tab2\", label: \"Tab 2\", content: \"Content for Tab 2.\" },\n { value: \"tab3\", label: \"Tab 3\", content: \"Content for Tab 3.\" },\n { value: \"tab4\", label: \"Tab 4\", content: \"Content for Tab 4.\" },\n ],\n defaultValue,\n onChange,\n sound = true,\n className,\n}: DragAndDropTabsProps) {\n const [items, setItems] = useState(defaultItems);\n const [active, setActive] = useState(\n defaultValue || defaultItems[0]?.value || \"\",\n );\n const lastSound = useRef(0);\n const lastOverId = useRef(null);\n const dropIdx = useRef(-1);\n const barRef = useRef(null);\n const measured = useRef(false);\n\n const pillX = useMotionValue(0);\n const pillW = useMotionValue(0);\n const springX = useSpring(pillX, SPRING);\n const springW = useSpring(pillW, SPRING);\n\n const sensors = useSensors(\n useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),\n );\n\n /* Measure active pill position */\n const measurePill = useCallback(() => {\n const bar = barRef.current;\n if (!bar) return;\n const idx = items.findIndex((t) => t.value === active);\n const btns = bar.querySelectorAll(\"[data-ddt-tab]\");\n const btn = btns[idx];\n if (!btn) return;\n // Use offsetLeft/offsetWidth — immune to ancestor CSS transforms (e.g. scale)\n const x = btn.offsetLeft;\n const w = btn.offsetWidth;\n if (!measured.current) {\n pillX.jump(x);\n pillW.jump(w);\n measured.current = true;\n } else {\n pillX.set(x);\n pillW.set(w);\n }\n }, [active, items, pillX, pillW]);\n\n useEffect(() => {\n /* Delay to let motion settle after reorder */\n const t = setTimeout(measurePill, 50);\n const ro = new ResizeObserver(measurePill);\n if (barRef.current) ro.observe(barRef.current);\n return () => {\n clearTimeout(t);\n ro.disconnect();\n };\n }, [measurePill]);\n\n const go = useCallback(\n (value: string) => {\n if (value === active) return;\n if (sound) tick(lastSound);\n setActive(value);\n onChange?.(value);\n },\n [active, onChange, sound],\n );\n\n const handleDragStart = useCallback(\n (_e: DragStartEvent) => {\n if (sound) tick(lastSound);\n },\n [sound],\n );\n\n const handleDragOver = useCallback(\n (e: DragOverEvent) => {\n const overId = e.over?.id as string | undefined;\n if (overId && overId !== lastOverId.current) {\n lastOverId.current = overId;\n if (sound) tick(lastSound);\n }\n },\n [sound],\n );\n\n const handleDragEnd = useCallback(\n (e: DragEndEvent) => {\n lastOverId.current = null;\n const { active: dragged, over } = e;\n if (dragged.id !== over?.id && over) {\n setItems((prev) => {\n const oldIdx = prev.findIndex(\n (t) => t.value === (dragged.id as string),\n );\n const newIdx = prev.findIndex((t) => t.value === (over.id as string));\n dropIdx.current = newIdx;\n setTimeout(() => {\n dropIdx.current = -1;\n }, 500);\n return arrayMove(prev, oldIdx, newIdx);\n });\n if (sound) tick(lastSound);\n }\n measured.current = false;\n },\n [sound],\n );\n\n const activeContent = items.find((t) => t.value === active)?.content;\n\n return (\n
\n