{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/components/reorderable-list/reorderable-list.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { GripVertical } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  Reorder,\n  motion,\n  useDragControls,\n  useMotionValue,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\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) => ({\n    opacity: 0,\n    y: `${0.5 * direction}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n  }),\n  center: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n  exit: (direction: number) => ({\n    opacity: 0,\n    y: `${-0.5 * direction}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n    transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] },\n  }),\n};\n\nexport function ReorderableList({\n  items,\n  onReorder,\n  label = \"Reorder items\",\n}: {\n  items: ReorderableItem[];\n  onReorder: (items: ReorderableItem[]) => void;\n  label?: string;\n}) {\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 (\n    <Reorder.Group\n      axis=\"y\"\n      values={items}\n      onReorder={onReorder}\n      className=\"m-0 grid w-full list-none gap-2 p-0\"\n      aria-label={label}\n    >\n      {items.map((item, index) => (\n        <Row\n          key={item.id}\n          item={item}\n          index={index}\n          count={items.length}\n          onMove={move}\n          lifted={moved === item.id}\n          handleRef={(element) => {\n            if (element) handles.current.set(item.id, element);\n            else handles.current.delete(item.id);\n          }}\n        />\n      ))}\n    </Reorder.Group>\n  );\n}\n\nfunction Row({\n  item,\n  index,\n  count,\n  onMove,\n  lifted,\n  handleRef,\n}: {\n  item: ReorderableItem;\n  index: number;\n  count: number;\n  onMove: (id: string, direction: -1 | 1) => void;\n  lifted: boolean;\n  handleRef: (element: HTMLButtonElement | null) => void;\n}) {\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 () => {\n      window.removeEventListener(\"pointerup\", release);\n      window.removeEventListener(\"pointercancel\", release);\n    };\n  }, [pressed]);\n  const [previous, setPrevious] = useState(index);\n  const [direction, setDirection] = useState(1);\n  if (previous !== index) {\n    setDirection(index > previous ? 1 : -1);\n    setPrevious(index);\n  }\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 (\n    <Reorder.Item\n      value={item}\n      dragControls={controls}\n      dragListener={false}\n      dragMomentum={false}\n      dragTransition={settle}\n      data-lifted={raised || undefined}\n      style={{ y }}\n      animate={{ scale: raised && !reduced ? 1.02 : 1 }}\n      onDragStart={() => setDragging(true)}\n      onDragEnd={() => {\n        setDragging(false);\n        if (reduced) y.jump(0);\n      }}\n      transition={reduced ? { duration: 0 } : { ...motionTokens.spring.smooth, scale: motionTokens.spring.snappy }}\n      // The lifted shadow lives on its own layer and fades in, so the row never repaints a growing shadow while it moves.\n      // Reorder.Item writes z-index inline while dragging; a keyboard lift needs the same stacking.\n      className=\"group/row relative flex min-h-14 min-w-0 items-center gap-3 rounded-control border border-line bg-surface py-[7px] pr-4 pl-[7px] text-ink shadow-resting select-none after:pointer-events-none after:absolute after:-inset-px after:rounded-[inherit] after:opacity-0 after:shadow-floating after:transition-opacity after:duration-standard after:ease-standard after:content-[''] data-lifted:z-1! data-lifted:after:opacity-100 data-lifted:after:duration-fast motion-reduce:after:transition-none\"\n    >\n      <button\n        ref={handleRef}\n        type=\"button\"\n        className=\"grid size-10 flex-none cursor-grab touch-none place-items-center rounded-button border-0 bg-hover text-ink-3 transition-[color,background-color] duration-fast ease-standard hover:text-ink active:cursor-grabbing group-data-lifted/row:cursor-grabbing group-data-lifted/row:bg-line group-data-lifted/row:text-ink motion-reduce:transition-none\"\n        aria-label={`Reorder ${item.title}. Position ${index + 1} of ${count}. Use arrow keys.`}\n        onPointerDown={(event) => {\n          if (event.button === 0) setPressed(true);\n          controls.start(event);\n        }}\n        onKeyDown={(event) => {\n          if (event.key === \"ArrowUp\" || event.key === \"ArrowDown\") {\n            event.preventDefault();\n            onMove(item.id, event.key === \"ArrowUp\" ? -1 : 1);\n          }\n        }}\n      >\n        <GripVertical size={16} aria-hidden=\"true\" />\n      </button>\n      <span className=\"grid min-w-0 gap-0.5\">\n        <strong className=\"truncate text-sm/[inherit] font-medium\">{item.title}</strong>\n        {item.detail && <small className=\"truncate text-xs/[inherit] text-ink-3\">{item.detail}</small>}\n      </span>\n      <span className=\"relative ml-auto grid text-xs/[inherit] text-ink-3 tabular-nums\">\n        <span className=\"sr-only\">{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\n            key={reduced ? \"static\" : index}\n            aria-hidden=\"true\"\n            className=\"col-start-1 row-start-1 block\"\n            custom={direction}\n            variants={roll}\n            initial={reduced ? false : \"enter\"}\n            animate=\"center\"\n            exit={reduced ? undefined : \"exit\"}\n            transition={{ duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}\n          >\n            {String(index + 1).padStart(2, \"0\")}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n    </Reorder.Item>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/reorderable-list/reorderable-list.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/reorderable-list",
  "meta": {
    "kind": "component",
    "group": "Data display",
    "export": "ReorderableList",
    "tags": [
      "data",
      "interaction"
    ],
    "docs": "https://irisui.dev/components/reorderable-list"
  }
}
