{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sortable-list",
  "title": "Sortable List",
  "description": "Drag-to-reorder list: grab a row's grip handle and drop it in a new place. Reach for it whenever the order itself is the data — reordering tasks or a to-do list, ranking priorities or search results, arranging table columns, form fields, dashboard widgets, nav or sidebar links, playlist tracks, image or gallery order, question order in a quiz, steps in a workflow or recipe, or the cards inside one kanban column. Common asks it answers: \"sortable list\", \"drag and drop list\", \"reorderable list\", \"drag to reorder\", \"drag handle list\", \"reorder items react\", \"sortable without dnd-kit\", \"react-beautiful-dnd replacement\", \"draggable list order\", \"move item up and down\". shadcn/ui ships nothing that reorders — there is no sortable, no draggable, no dnd primitive anywhere in the catalog — so this is hand-rolled every time, and the half that gets dropped is always the keyboard. Here the whole interaction works without a mouse: Tab reaches the list once (roving tabindex), arrow keys walk it, Space or Enter picks a row up, arrows move the picked-up row, Space or Enter drops it, Escape puts it back where it started, and each step is spoken through an assertive live region (\"Picked up Design review. Position 2 of 5.\"). Every announcement is overridable through `labels` for other languages. Dragging is plain pointer events — no dnd-kit, no react-dnd, no HTML5 drag-and-drop — so touch works, rows are measured once per drag and displaced with transforms, and rows of different heights land exactly where they look like they will. Controlled: pass `items` (`{ id, label }` plus whatever else you carry) and persist the array `onReorder` hands back; `renderItem` draws the row body beside the handle. Depends only on lucide-react and your cn util.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/sortable-list.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { GripVertical } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface SortableListItem {\n  /** Stable across reorders — it keys the row, so React moves the node instead of rebuilding it. */\n  id: string\n  /** Plain text used for the handle's accessible name and for the drag announcements. */\n  label: string\n}\n\n/** Every string a screen reader hears. Override to translate or to reword. */\nexport interface SortableListLabels {\n  /** Accessible name of a row's drag handle. */\n  handle: (label: string) => string\n  /** Read once when the handle takes focus, via aria-describedby. */\n  instructions: string\n  grabbed: (label: string, position: number, total: number) => string\n  moved: (label: string, position: number, total: number) => string\n  dropped: (label: string, position: number, total: number) => string\n  cancelled: (label: string) => string\n}\n\nconst defaultLabels: SortableListLabels = {\n  handle: (label) => `Reorder ${label}`,\n  instructions:\n    \"Press space or enter to pick the item up, arrow keys to move it, space or enter to drop it, escape to cancel.\",\n  grabbed: (label, position, total) => `Picked up ${label}. Position ${position} of ${total}.`,\n  moved: (label, position, total) => `${label} is now at position ${position} of ${total}.`,\n  dropped: (label, position, total) => `Dropped ${label} at position ${position} of ${total}.`,\n  cancelled: (label) => `Reordering cancelled. ${label} is back where it started.`,\n}\n\nexport interface SortableListRenderState {\n  index: number\n  /** The row is being dragged with a pointer right now. */\n  dragging: boolean\n  /** The row has been picked up with the keyboard and is waiting to be dropped. */\n  grabbed: boolean\n}\n\ninterface SortableListProps<T extends SortableListItem> {\n  /** The current order. This component is controlled: it never reorders `items` itself. */\n  items: T[]\n  /** Receives the whole array in its new order — persist it and pass it back as `items`. */\n  onReorder: (items: T[]) => void\n  /** Row body to the right of the handle. Defaults to `item.label`. */\n  renderItem?: (item: T, state: SortableListRenderState) => React.ReactNode\n  labels?: Partial<SortableListLabels>\n  className?: string\n  /** Applied to every row, so the default card look can be replaced wholesale. */\n  itemClassName?: string\n  /** Name the list (or point `aria-labelledby` at a visible heading). */\n  \"aria-label\"?: string\n  \"aria-labelledby\"?: string\n}\n\ninterface RowRect {\n  top: number\n  height: number\n}\n\ninterface DragState {\n  id: string\n  /** Index the row started at. */\n  from: number\n  /** Index it would land on if dropped now. */\n  to: number\n  pointerId: number\n  startY: number\n  dy: number\n  /** Row geometry measured once at drag start, so moving rows don't feed back into the math. */\n  rects: RowRect[]\n  /**\n   * How far a displaced row travels: the space the dragged row vacates, which is its own\n   * height plus one gap — the same for every displaced row however tall *they* are, because\n   * they only ever close up over the one row that left.\n   */\n  shift: number\n}\n\n// useLayoutEffect puts focus back on the handle before paint, so a keyboard move never lands on\n// <body> for a frame; it warns during SSR — fall back to useEffect on the server.\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\nfunction move<T>(list: T[], from: number, to: number): T[] {\n  const next = list.slice()\n  const [item] = next.splice(from, 1)\n  next.splice(to, 0, item)\n  return next\n}\n\n/**\n * A list whose rows can be reordered by dragging the grip handle — or entirely from the\n * keyboard: Tab to the list, arrow keys to walk it, Space to pick a row up, arrows to move\n * it, Space to drop, Escape to put it back. Every step is announced through a live region.\n *\n * Controlled by design: pass `items` and persist what `onReorder` hands back. Rows keep the\n * caller's own objects, so `onReorder` returns them unchanged apart from their order.\n *\n * Dragging is plain pointer events — no dnd-kit, no HTML5 drag-and-drop (whose drag image\n * and dragover semantics are the usual source of touch-device bugs). Rows are measured once\n * when a drag starts and displaced with transforms, so rows of different heights land where\n * they look like they will.\n */\nexport function SortableList<T extends SortableListItem>({\n  items,\n  onReorder,\n  renderItem,\n  labels: labelOverrides,\n  className,\n  itemClassName,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n}: SortableListProps<T>) {\n  const labels = { ...defaultLabels, ...labelOverrides }\n\n  const instructionsId = React.useId()\n  const listRef = React.useRef<HTMLUListElement>(null)\n  const handleRefs = React.useRef(new Map<string, HTMLButtonElement>())\n\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  const [drag, setDrag] = React.useState<DragState | null>(null)\n  const [grabbedId, setGrabbedId] = React.useState<string | null>(null)\n  const [focusedId, setFocusedId] = React.useState<string | null>(null)\n\n  /** The order at the moment of the keyboard pick-up, so Escape can restore it. */\n  const grabOrigin = React.useRef<T[] | null>(null)\n  /** A row whose handle should keep focus once a reorder that ends the interaction commits. */\n  const refocusId = React.useRef<string | null>(null)\n\n  // One tab stop for the whole list (roving tabindex). Falling back to the first row keeps\n  // the list reachable before anything is focused, and re-resolves when the focused row is\n  // removed by the parent.\n  const activeId = React.useMemo(() => {\n    const ids = new Set(items.map((i) => i.id))\n    if (focusedId && ids.has(focusedId)) return focusedId\n    return items[0]?.id ?? null\n  }, [items, focusedId])\n\n  // Any reorder re-renders the list with the row in a new slot, and React moves the row's DOM\n  // node to get there. Pull focus back onto the handle afterwards so keystrokes keep landing\n  // on the row the user is carrying — and so the row stays reachable after the final drop —\n  // whatever the browser decides to do with focus while the node is moving.\n  useIsomorphicLayoutEffect(() => {\n    const id = grabbedId ?? refocusId.current\n    refocusId.current = null\n    if (id) handleRefs.current.get(id)?.focus()\n  }, [grabbedId, items])\n\n  function focusHandle(id: string) {\n    setFocusedId(id)\n    handleRefs.current.get(id)?.focus()\n  }\n\n  function endGrab() {\n    grabOrigin.current = null\n    setGrabbedId(null)\n  }\n\n  function handleKeyDown(e: React.KeyboardEvent, index: number) {\n    // A pointer drag owns the list while it lasts; Escape abandons it.\n    if (drag) {\n      if (e.key === \"Escape\") {\n        e.preventDefault()\n        setDrag(null)\n      }\n      return\n    }\n\n    const item = items[index]\n    const total = items.length\n    const isGrabbed = grabbedId === item.id\n\n    switch (e.key) {\n      case \" \":\n      case \"Enter\":\n        // Space on a button would also fire click; preventDefault keeps the pick-up from\n        // being undone by the click that follows it.\n        e.preventDefault()\n        if (isGrabbed) {\n          endGrab()\n          setAnnouncement(labels.dropped(item.label, index + 1, total))\n        } else {\n          grabOrigin.current = items\n          setGrabbedId(item.id)\n          setAnnouncement(labels.grabbed(item.label, index + 1, total))\n        }\n        break\n\n      case \"Escape\": {\n        if (!isGrabbed) break\n        e.preventDefault()\n        const origin = grabOrigin.current\n        endGrab()\n        if (origin) {\n          refocusId.current = item.id\n          onReorder(origin)\n        }\n        setAnnouncement(labels.cancelled(item.label))\n        break\n      }\n\n      case \"ArrowUp\":\n      case \"ArrowDown\":\n      case \"Home\":\n      case \"End\": {\n        e.preventDefault()\n        const next =\n          e.key === \"Home\"\n            ? 0\n            : e.key === \"End\"\n              ? total - 1\n              : index + (e.key === \"ArrowDown\" ? 1 : -1)\n        if (next < 0 || next >= total || next === index) break\n        if (isGrabbed) {\n          onReorder(move(items, index, next))\n          setAnnouncement(labels.moved(item.label, next + 1, total))\n        } else {\n          focusHandle(items[next].id)\n        }\n        break\n      }\n    }\n  }\n\n  function handleBlur() {\n    if (!grabbedId) return\n    // Moving a focused node can fire blur in some browsers even though focus comes straight\n    // back to it. Wait a frame and only end the grab if focus really left the list —\n    // otherwise every keyboard move would drop the row it just picked up.\n    requestAnimationFrame(() => {\n      const root = listRef.current\n      if (!root || root.contains(document.activeElement)) return\n      endGrab()\n    })\n  }\n\n  function handlePointerDown(e: React.PointerEvent<HTMLButtonElement>, index: number) {\n    if (e.pointerType === \"mouse\" && e.button !== 0) return\n    const root = listRef.current\n    if (!root) return\n\n    const rows = Array.from(root.querySelectorAll<HTMLLIElement>(\":scope > [data-sortable-row]\"))\n    if (rows.length !== items.length) return\n    const rects = rows.map((row) => {\n      const box = row.getBoundingClientRect()\n      return { top: box.top, height: box.height }\n    })\n\n    // Read the row spacing off the page rather than assuming the default gap, so a caller\n    // who restyles the rows through `className` still gets rows that line up.\n    const gap = rects.length > 1 ? rects[1].top - rects[0].top - rects[0].height : 0\n\n    const handle = e.currentTarget\n    // Stops the press from selecting text or scrolling the page; focus is then set by hand,\n    // because preventDefault also suppresses the focus the press would have given us.\n    e.preventDefault()\n    handle.setPointerCapture(e.pointerId)\n    handle.focus()\n    setFocusedId(items[index].id)\n    endGrab()\n    setDrag({\n      id: items[index].id,\n      from: index,\n      to: index,\n      pointerId: e.pointerId,\n      startY: e.clientY,\n      dy: 0,\n      rects,\n      shift: rects[index].height + gap,\n    })\n  }\n\n  function handlePointerMove(e: React.PointerEvent) {\n    if (!drag || e.pointerId !== drag.pointerId) return\n    const { rects, from } = drag\n    const first = rects[0]\n    const last = rects[rects.length - 1]\n    const restingCenter = rects[from].top + rects[from].height / 2\n\n    // Two bounds, and the drag gets whichever is looser. The row should not be draggable off\n    // into the page, but it also has to be able to *reach* the end slots — and since the slot\n    // is picked from the row's midpoint, a row taller than the end row would have its midpoint\n    // stop short of that row's midpoint while its edge was already flush with the list.\n    const dy = Math.max(\n      Math.min(first.top - rects[from].top, first.top + first.height / 2 - restingCenter),\n      Math.min(\n        Math.max(\n          last.top + last.height - (rects[from].top + rects[from].height),\n          last.top + last.height / 2 - restingCenter\n        ),\n        e.clientY - drag.startY\n      )\n    )\n\n    // Land on whichever slot the row's own midpoint has reached, comparing against the\n    // midpoints as they were before anything moved. The comparison includes equality so the\n    // clamped extremes above count as arriving; at rest a neighbour's midpoint is always\n    // strictly past this one's, so that costs no spurious swap.\n    const center = restingCenter + dy\n    let to = from\n    while (to < rects.length - 1 && center >= rects[to + 1].top + rects[to + 1].height / 2) to++\n    while (to > 0 && center <= rects[to - 1].top + rects[to - 1].height / 2) to--\n\n    if (dy === drag.dy && to === drag.to) return\n    setDrag({ ...drag, dy, to })\n  }\n\n  function handlePointerUp(e: React.PointerEvent) {\n    if (!drag || e.pointerId !== drag.pointerId) return\n    const { from, to } = drag\n    setDrag(null)\n    if (from === to) return\n    refocusId.current = drag.id\n    onReorder(move(items, from, to))\n    setAnnouncement(labels.dropped(items[from].label, to + 1, items.length))\n  }\n\n  function handlePointerCancel(e: React.PointerEvent) {\n    if (!drag || e.pointerId !== drag.pointerId) return\n    setDrag(null)\n  }\n\n  /**\n   * Where a row sits during a drag. The dragged row follows the pointer; every row the drag\n   * has passed closes up over the space the dragged row left, which puts each of them exactly\n   * where it will be once the drop commits — so only the dragged row itself has to snap.\n   */\n  function transformFor(index: number): string | undefined {\n    if (!drag) return undefined\n    const { from, to, dy, rects, shift } = drag\n    // The snapshot is only valid for the list that was measured; if the parent adds or\n    // removes rows mid-drag, leave everything where it is rather than displace by stale sizes.\n    if (rects.length !== items.length) return undefined\n    if (index === from) return `translateY(${dy}px)`\n    if (to > from && index > from && index <= to) return `translateY(${-shift}px)`\n    if (to < from && index >= to && index < from) return `translateY(${shift}px)`\n    return undefined\n  }\n\n  return (\n    <div>\n      <ul\n        ref={listRef}\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabelledby}\n        className={cn(\"flex flex-col gap-1\", className)}\n      >\n        {items.map((item, index) => {\n          const dragging = drag?.id === item.id\n          const grabbed = grabbedId === item.id\n\n          return (\n            <li\n              key={item.id}\n              data-sortable-row=\"\"\n              style={{ transform: transformFor(index), zIndex: dragging ? 1 : undefined }}\n              className={cn(\n                \"relative flex items-center gap-2 rounded-md border bg-card px-2 py-2 text-sm text-card-foreground\",\n                // Only the rows getting out of the way animate, and only while a drag is in\n                // progress: on drop the transforms and this class are removed in the same\n                // render as the reorder, so nothing slides back through its old slot.\n                drag && !dragging && \"transition-transform duration-150\",\n                dragging && \"shadow-lg\",\n                (dragging || grabbed) && \"border-ring\",\n                itemClassName\n              )}\n            >\n              <button\n                type=\"button\"\n                ref={(el) => {\n                  if (el) handleRefs.current.set(item.id, el)\n                  else handleRefs.current.delete(item.id)\n                }}\n                aria-label={labels.handle(item.label)}\n                aria-roledescription=\"sortable item\"\n                aria-describedby={instructionsId}\n                aria-pressed={grabbed}\n                tabIndex={activeId === item.id ? 0 : -1}\n                onKeyDown={(e) => handleKeyDown(e, index)}\n                onPointerDown={(e) => handlePointerDown(e, index)}\n                onPointerMove={handlePointerMove}\n                onPointerUp={handlePointerUp}\n                onPointerCancel={handlePointerCancel}\n                onFocus={() => setFocusedId(item.id)}\n                onBlur={handleBlur}\n                // touch-none keeps a touch drag from scrolling the page instead of the row.\n                className={cn(\n                  \"-ml-0.5 flex h-7 w-6 shrink-0 touch-none items-center justify-center rounded text-muted-foreground\",\n                  \"hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n                  dragging ? \"cursor-grabbing\" : \"cursor-grab\"\n                )}\n              >\n                <GripVertical className=\"h-4 w-4\" aria-hidden=\"true\" />\n              </button>\n\n              <div className=\"min-w-0 flex-1\">\n                {renderItem ? renderItem(item, { index, dragging, grabbed }) : item.label}\n              </div>\n            </li>\n          )\n        })}\n      </ul>\n\n      <div id={instructionsId} className=\"sr-only\">\n        {labels.instructions}\n      </div>\n      {/* Assertive, so a fast run of arrow presses reports where the row is now rather than\n          queueing up every position it passed through. */}\n      <div aria-live=\"assertive\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}