{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reorderable-list",
  "type": "registry:ui",
  "title": "Reorderable list",
  "description": "Organize items with direct drag and drop.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/reorderable-list/reorderable-list.tsx",
      "type": "registry:component",
      "target": "@components/xzero/reorderable-list/reorderable-list.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { GripVertical } from \"lucide-react\";\nimport { AnimatePresence, Reorder, motion, useDragControls, useMotionValue, useReducedMotion, type Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./reorderable-list.module.css\";\n\nexport type ReorderableItem = { id: string; title: string; detail?: string };\n\n/** The drop settles on a spring close to `spring.snappy`: quick, with a hint of give. */\nconst settle = { bounceStiffness: 520, bounceDamping: 40 };\nconst roll: Variants = {\n  enter: (direction: number) => ({ opacity: 0, y: `${.5 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)` }),\n  center: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n  exit: (direction: number) => ({ opacity: 0, y: `${-.5 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }),\n};\n\nexport function ReorderableList({ items, onReorder, label = \"Reorder items\" }: { items: ReorderableItem[]; onReorder: (items: ReorderableItem[]) => void; label?: string }) {\n  const [moved, setMoved] = useState<string | null>(null);\n  const handles = useRef(new Map<string, HTMLButtonElement>());\n  useEffect(() => {\n    if (!moved) return;\n    // Reordering moves DOM nodes, which can drop focus. Keep it on the handle that was moved.\n    const handle = handles.current.get(moved);\n    if (handle && document.activeElement !== handle) handle.focus();\n    const timer = window.setTimeout(() => setMoved(null), 260);\n    return () => window.clearTimeout(timer);\n  }, [moved, items]);\n  function move(id: string, direction: -1 | 1) {\n    const index = items.findIndex(item => item.id === id);\n    const next = index + direction;\n    if (next < 0 || next >= items.length) return;\n    const copy = [...items];\n    [copy[index], copy[next]] = [copy[next]!, copy[index]!];\n    setMoved(id);\n    onReorder(copy);\n  }\n  return <Reorder.Group axis=\"y\" values={items} onReorder={onReorder} className={styles.list} aria-label={label}>\n    {items.map((item, index) => <Row key={item.id} item={item} index={index} count={items.length} onMove={move} lifted={moved === item.id} handleRef={element => { if (element) handles.current.set(item.id, element); else handles.current.delete(item.id); }} />)}\n  </Reorder.Group>;\n}\n\nfunction Row({ item, index, count, onMove, lifted, handleRef }: { item: ReorderableItem; index: number; count: number; onMove: (id: string, direction: -1 | 1) => void; lifted: boolean; handleRef: (element: HTMLButtonElement | null) => void }) {\n  const controls = useDragControls();\n  const reduced = useReducedMotion() ?? false;\n  const [dragging, setDragging] = useState(false);\n  // The row lifts as soon as the handle is pressed, before the drag threshold, and settles if released in place.\n  const [pressed, setPressed] = useState(false);\n  useEffect(() => {\n    if (!pressed) return;\n    const release = () => setPressed(false);\n    window.addEventListener(\"pointerup\", release);\n    window.addEventListener(\"pointercancel\", release);\n    return () => { window.removeEventListener(\"pointerup\", release); window.removeEventListener(\"pointercancel\", release); };\n  }, [pressed]);\n  const [previous, setPrevious] = useState(index);\n  const [direction, setDirection] = useState(1);\n  if (previous !== index) { setDirection(index > previous ? 1 : -1); setPrevious(index); }\n  const raised = dragging || lifted || pressed;\n  // Reduced motion: the row still follows the pointer, but lands in its slot at once instead of gliding there.\n  const y = useMotionValue(0);\n  return <Reorder.Item value={item} dragControls={controls} dragListener={false} dragMomentum={false} dragTransition={settle} data-lifted={raised || undefined}\n    style={{ y }}\n    animate={{ scale: raised && !reduced ? 1.02 : 1 }}\n    onDragStart={() => setDragging(true)} onDragEnd={() => { setDragging(false); if (reduced) y.jump(0); }}\n    transition={reduced ? { duration: 0 } : { ...motionTokens.spring.smooth, scale: motionTokens.spring.snappy }} className={styles.row}>\n    <button ref={handleRef} type=\"button\" className={styles.handle} aria-label={`Reorder ${item.title}. Position ${index + 1} of ${count}. Use arrow keys.`} onPointerDown={event => { if (event.button === 0) setPressed(true); controls.start(event); }} onKeyDown={event => { if (event.key === \"ArrowUp\" || event.key === \"ArrowDown\") { event.preventDefault(); onMove(item.id, event.key === \"ArrowUp\" ? -1 : 1); } }}><GripVertical size={16} aria-hidden=\"true\" /></button>\n    <span className={styles.copy}><strong>{item.title}</strong>{item.detail && <small>{item.detail}</small>}</span>\n    <span className={styles.position}><span className={styles.srOnly}>{String(index + 1).padStart(2, \"0\")}</span>\n      {/* The number rolls in the direction the row travelled, so a new position reads as movement. Reduced motion keeps one node and swaps the text:\n          removing a keyed digit mid-drag re-measures the row and would throw it off the pointer. */}\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n        <motion.span key={reduced ? \"static\" : index} aria-hidden=\"true\" className={styles.digits} custom={direction} variants={roll} initial={reduced ? false : \"enter\"} animate=\"center\" exit={reduced ? undefined : \"exit\"} transition={{ duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{String(index + 1).padStart(2, \"0\")}</motion.span>\n      </AnimatePresence>\n    </span>\n  </Reorder.Item>;\n}\n"
    },
    {
      "path": "registry/components/reorderable-list/reorderable-list.module.css",
      "type": "registry:component",
      "target": "@components/xzero/reorderable-list/reorderable-list.module.css",
      "content": ".list { display: grid; gap: 8px; width: 100%; margin: 0; padding: 0; list-style: none; }\n.row { position: relative; display: flex; min-width: 0; align-items: center; gap: 12px; min-height: 56px; border: 1px solid var(--border); border-radius: var(--radius-control); padding: 7px 16px 7px 7px; background: var(--surface); box-shadow: var(--shadow-resting); color: var(--foreground); user-select: none; }\n/* The lifted shadow lives on its own layer and fades in, so the row never repaints a growing shadow while it moves. */\n.row::after { position: absolute; inset: -1px; border-radius: inherit; box-shadow: var(--shadow-floating); content: \"\"; opacity: 0; pointer-events: none; transition: opacity var(--duration-standard) var(--ease-standard); }\n/* Reorder.Item writes z-index inline while dragging; a keyboard lift needs the same stacking. */\n.row[data-lifted] { z-index: 1 !important; }\n.row[data-lifted]::after { opacity: 1; transition-duration: var(--duration-fast); }\n/* A 40px handle in a 56px row sits 7px from the top, bottom and left edges, so its corner follows the row's. */\n.handle { display: grid; width: 40px; height: 40px; flex: 0 0 auto; place-items: center; border: 0; border-radius: calc(var(--radius-control) - 7px); background: var(--surface-muted); color: var(--text-muted); cursor: grab; touch-action: none; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }\n.row[data-lifted] .handle { background: var(--border); color: var(--foreground); cursor: grabbing; }\n.handle:active { cursor: grabbing; }\n.copy { display: grid; min-width: 0; gap: 2px; }\n.copy strong { overflow: hidden; font-size: var(--text-sm); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }\n.copy small { overflow: hidden; color: var(--text-muted); font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; }\n.position { position: relative; display: grid; margin-left: auto; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n.digits { display: block; grid-area: 1 / 1; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n@media (hover: hover) and (pointer: fine) { .handle:hover { color: var(--foreground); } }\n@media (prefers-reduced-motion: reduce) { .row::after, .handle { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/reorderable-list",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "interaction"
    ],
    "docs": "https://ui.xzero.co.uk/components/reorderable-list",
    "markdown": "https://ui.xzero.co.uk/components/reorderable-list/markdown"
  }
}