{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sortable-grid","type":"registry:component","title":"Sortable grid","description":"Drag an app tile and the others slide out of its way.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/sortable-grid.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { animate, motion, useMotionTemplate, useMotionValue } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type SortableGridItem = { id: string; label: string; icon: React.ReactNode };\n\nconst COLS = 3;\n// Positions are in percent of a tile's own size, so the grid needs no\n// measuring to render: the gap is 10% of a tile, one slot is 110%.\nconst PITCH = 110;\nconst TILE_WIDTH = `${100 / (COLS + (COLS - 1) * (PITCH / 100 - 1))}%`;\n// Mouse drags start after a small move, so a sloppy click stays a click.\nconst MOUSE_THRESHOLD = 4;\n// Touch waits for a hold instead, leaving quick swipes to scroll the page.\nconst LONG_PRESS = 250;\n// A finger that moves this far before the hold completes is scrolling.\nconst TOUCH_SLOP = 8;\n// How far past the grid edge a tile can be pulled, in percent of a tile,\n// before it stiffens. Small enough that it never widens the page.\nconst EDGE_GIVE = 8;\nconst LIFT_SCALE = 1.05;\n// The lab-wide press scale, so a tap feels the same here as on any button.\nconst PRESS_SCALE = 0.96;\n// No bounce anywhere: a tile that overshoots its slot reads as a wrong drop.\nconst SHIFT = { type: \"spring\", duration: 0.35, bounce: 0 } as const;\n// Starts from the release velocity, so a flung tile glides home.\nconst DROP = { type: \"spring\", duration: 0.4, bounce: 0 } as const;\nconst LIFT = { type: \"spring\", duration: 0.25, bounce: 0 } as const;\nconst PRESS = { type: \"spring\", duration: 0.15, bounce: 0 } as const;\n// Layered and transparent so it darkens whatever is underneath. The 1px ring\n// keeps the lifted edge visible in dark mode, where shadows barely show.\nconst LIFTED_SHADOW = [\n  \"0 0 0 1px light-dark(oklch(0 0 0 / 0.06), oklch(1 0 0 / 0.1))\",\n  \"0 4px 8px light-dark(oklch(0 0 0 / 0.06), oklch(0 0 0 / 0.4))\",\n  \"0 20px 40px -12px light-dark(oklch(0 0 0 / 0.24), oklch(0 0 0 / 0.8))\",\n].join(\", \");\n\nconst slot = (index: number) => ({\n  x: (index % COLS) * PITCH,\n  y: Math.floor(index / COLS) * PITCH,\n});\n\n// Gives freely at first, then stiffens so it never passes EDGE_GIVE.\nfunction resist(value: number, max: number) {\n  if (value < 0) return EDGE_GIVE * Math.tanh(value / (EDGE_GIVE * 3));\n  if (value > max) return max + EDGE_GIVE * Math.tanh((value - max) / (EDGE_GIVE * 3));\n  return value;\n}\n\nexport function SortableGrid({\n  items,\n  onReorder,\n  label,\n  className,\n}: {\n  items: SortableGridItem[];\n  onReorder: (items: SortableGridItem[]) => void;\n  label: string;\n  className?: string;\n}) {\n  const reduceMotion = !!useReducedMotion();\n  const gridRef = useRef<HTMLDivElement>(null);\n  const buttons = useRef(new Map<string, HTMLButtonElement>());\n  const [dragId, setDragId] = useState<string | null>(null);\n  const [held, setHeld] = useState<{ id: string; before: SortableGridItem[] } | null>(\n    null,\n  );\n  const [focusId, setFocusId] = useState(items[0]?.id);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const instructions = useId();\n  const rows = Math.ceil(items.length / COLS);\n\n  // Pointer handlers outlive the render that created them.\n  const latest = useRef({ items, onReorder });\n  const dragging = useRef(false);\n  useEffect(() => {\n    latest.current = { items, onReorder };\n  });\n\n  // Once a hold turns into a drag, the page must stop scrolling. Only a\n  // non-passive native listener can cancel touchmove.\n  useEffect(() => {\n    const grid = gridRef.current;\n    if (!grid) return;\n    const block = (e: TouchEvent) => {\n      if (dragging.current && e.cancelable) e.preventDefault();\n    };\n    grid.addEventListener(\"touchmove\", block, { passive: false });\n    return () => grid.removeEventListener(\"touchmove\", block);\n  }, []);\n\n  const name = (id: string) => items.find((i) => i.id === id)?.label ?? \"\";\n  const place = (index: number) =>\n    `row ${Math.floor(index / COLS) + 1}, column ${(index % COLS) + 1}`;\n\n  const moveTo = (id: string, target: number) => {\n    const list = latest.current.items;\n    const from = list.findIndex((i) => i.id === id);\n    const to = Math.max(0, Math.min(target, list.length - 1));\n    if (from === to || from < 0) return false;\n    const next = [...list];\n    next.splice(to, 0, ...next.splice(from, 1));\n    // Kept current right away, so moves within one frame see the new order.\n    latest.current.items = next;\n    latest.current.onReorder(next);\n    return true;\n  };\n\n  const pointer = {\n    // One percent of a tile, in px, measured when a drag starts.\n    unit: () => (gridRef.current?.offsetWidth ?? 0) / (100 * COLS + (PITCH - 100) * (COLS - 1)),\n    lift: (id: string) => {\n      dragging.current = true;\n      setDragId(id);\n    },\n    move: (id: string, x: number, y: number) => {\n      const col = Math.max(0, Math.min(Math.round(x / PITCH), COLS - 1));\n      const row = Math.max(0, Math.min(Math.round(y / PITCH), rows - 1));\n      moveTo(id, row * COLS + col);\n    },\n    drop: (id: string) => {\n      dragging.current = false;\n      setDragId(null);\n      const index = latest.current.items.findIndex((i) => i.id === id);\n      setAnnouncement(`Dropped ${name(id)} at ${place(index)}.`);\n    },\n    busy: () => dragging.current || held !== null,\n  };\n\n  const onKeyDown = (id: string, e: React.KeyboardEvent) => {\n    const index = items.findIndex((i) => i.id === id);\n    const delta = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -COLS, ArrowDown: COLS }[\n      e.key\n    ];\n\n    if (e.key === \" \" || e.key === \"Enter\") {\n      e.preventDefault();\n      if (e.repeat || dragging.current) return;\n      if (held?.id === id) {\n        setHeld(null);\n        setAnnouncement(`Dropped ${name(id)} at ${place(index)}.`);\n      } else {\n        setHeld({ id, before: items });\n        setAnnouncement(\n          `Picked up ${name(id)} at ${place(index)}. Arrow keys move it, Space drops it, Escape cancels.`,\n        );\n      }\n      return;\n    }\n    if (e.key === \"Escape\" && held?.id === id) {\n      e.preventDefault();\n      onReorder(held.before);\n      setHeld(null);\n      const back = held.before.findIndex((i) => i.id === id);\n      setAnnouncement(`Cancelled. ${name(id)} is back at ${place(back)}.`);\n      return;\n    }\n    if (delta === undefined) return;\n    e.preventDefault();\n    const target = index + delta;\n    // Up and down stop at the edges instead of wrapping to another column.\n    if (target < 0 || target >= items.length) return;\n    if (held?.id === id) {\n      if (moveTo(id, target)) setAnnouncement(`Moved ${name(id)} to ${place(target)}.`);\n    } else {\n      const next = items[target].id;\n      setFocusId(next);\n      buttons.current.get(next)?.focus();\n    }\n  };\n\n  return (\n    <div\n      className={cn(\n        // 28 = 16 (tile radius) + 12 (padding): concentric corners.\n        \"w-[min(420px,100%)] rounded-[28px] bg-muted p-3 shadow-raised\",\n        className,\n      )}\n    >\n      <div\n        ref={gridRef}\n        role=\"group\"\n        aria-label={label}\n        aria-roledescription=\"sortable grid\"\n        className=\"relative\"\n        // Height follows width, so it never shifts while tiles move.\n        style={{ aspectRatio: `${COLS * 100 + (COLS - 1) * (PITCH - 100)} / ${rows * 100 + (rows - 1) * (PITCH - 100)}` }}\n      >\n        {/* Rendered in a stable order and placed by transform, so reordering\n            never moves DOM nodes out from under focus or a drag. */}\n        {[...items]\n          .sort((a, b) => a.id.localeCompare(b.id))\n          .map((item) => {\n            const index = items.indexOf(item);\n            return (\n              <Tile\n                key={item.id}\n                item={item}\n                index={index}\n                total={items.length}\n                lifted={dragId === item.id || held?.id === item.id}\n                held={held?.id === item.id}\n                focusable={focusId === item.id}\n                reduceMotion={reduceMotion}\n                describedBy={instructions}\n                pointer={pointer}\n                buttonRef={(el) => {\n                  if (el) buttons.current.set(item.id, el);\n                  else buttons.current.delete(item.id);\n                }}\n                onFocus={() => setFocusId(item.id)}\n                onBlur={() => {\n                  // Tabbing away while holding a tile drops it where it is.\n                  if (held?.id === item.id) {\n                    setHeld(null);\n                    setAnnouncement(`Dropped ${item.label} at ${place(index)}.`);\n                  }\n                }}\n                onKeyDown={(e) => onKeyDown(item.id, e)}\n              />\n            );\n          })}\n      </div>\n      <p id={instructions} className=\"sr-only\">\n        Drag to reorder, or press Space to pick up, arrow keys to move, Space\n        to drop and Escape to cancel.\n      </p>\n      <p className=\"sr-only\" aria-live=\"assertive\" aria-atomic>\n        {announcement}\n      </p>\n    </div>\n  );\n}\n\ntype PointerApi = {\n  unit: () => number;\n  lift: (id: string) => void;\n  move: (id: string, x: number, y: number) => void;\n  drop: (id: string) => void;\n  busy: () => boolean;\n};\n\nfunction Tile({\n  item,\n  index,\n  total,\n  lifted,\n  held,\n  focusable,\n  reduceMotion,\n  describedBy,\n  pointer,\n  buttonRef,\n  onFocus,\n  onBlur,\n  onKeyDown,\n}: {\n  item: SortableGridItem;\n  index: number;\n  total: number;\n  lifted: boolean;\n  held: boolean;\n  focusable: boolean;\n  reduceMotion: boolean;\n  describedBy: string;\n  pointer: PointerApi;\n  buttonRef: (el: HTMLButtonElement | null) => void;\n  onFocus: () => void;\n  onBlur: () => void;\n  onKeyDown: (e: React.KeyboardEvent) => void;\n}) {\n  const home = slot(index);\n  const x = useMotionValue(home.x);\n  const y = useMotionValue(home.y);\n  const scale = useMotionValue(1);\n  const zIndex = useMotionValue(0);\n  const transform = useMotionTemplate`translate(${x}%, ${y}%) scale(${scale})`;\n  const dragging = useRef(false);\n  const state = useRef({ index, lifted, reduceMotion, pointer });\n  const cleanup = useRef<() => void>(undefined);\n\n  useEffect(() => {\n    state.current = { index, lifted, reduceMotion, pointer };\n  });\n\n  useEffect(() => () => cleanup.current?.(), []);\n\n  const settle = (velocity?: { x: number; y: number }) => {\n    const target = slot(state.current.index);\n    if (state.current.reduceMotion) {\n      x.jump(target.x);\n      y.jump(target.y);\n      if (!state.current.lifted) zIndex.set(0);\n      return;\n    }\n    const spring = velocity ? DROP : SHIFT;\n    const flights = [\n      animate(x, target.x, { ...spring, velocity: velocity?.x }),\n      animate(y, target.y, { ...spring, velocity: velocity?.y }),\n    ];\n    // Stays above its neighbours until it has landed.\n    Promise.all(flights).then(() => {\n      if (!state.current.lifted && !dragging.current) zIndex.set(0);\n    });\n  };\n\n  // Every tile but the one under the pointer glides to its new slot. The\n  // keyboard-held tile moves the same way, so both paths look alike.\n  useEffect(() => {\n    if (!dragging.current) settle();\n    // settle reads everything else from refs.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [index]);\n\n  // Keyboard lift and drop. A pointer drop lowers the tile itself, once it\n  // has landed (see settle), so it never slips under a neighbour mid-flight.\n  useEffect(() => {\n    const keyboardDrop = !lifted && zIndex.get() === 2;\n    if (lifted) zIndex.set(2);\n    if (reduceMotion) {\n      scale.jump(1);\n      if (keyboardDrop) zIndex.set(0);\n      return;\n    }\n    if (dragging.current) return;\n    const lift = animate(scale, lifted ? LIFT_SCALE : 1, LIFT);\n    if (keyboardDrop) {\n      zIndex.set(1);\n      lift.then(() => {\n        if (!state.current.lifted) zIndex.set(0);\n      });\n    }\n  }, [lifted, reduceMotion, scale, zIndex]);\n\n  const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {\n    if (e.button !== 0 || !e.isPrimary || state.current.pointer.busy()) return;\n    const touch = e.pointerType === \"touch\";\n    const start = { x: e.clientX, y: e.clientY };\n    const origin = { x: x.get(), y: y.get() };\n    const id = item.id;\n    const max = { x: (COLS - 1) * PITCH, y: (Math.ceil(total / COLS) - 1) * PITCH };\n    let unit = 1;\n    let last = start;\n    let timer: ReturnType<typeof setTimeout> | undefined;\n\n    if (!state.current.reduceMotion) animate(scale, PRESS_SCALE, PRESS);\n\n    const follow = () => {\n      const nx = resist(origin.x + (last.x - start.x) / unit, max.x);\n      const ny = resist(origin.y + (last.y - start.y) / unit, max.y);\n      x.set(nx);\n      y.set(ny);\n      state.current.pointer.move(id, nx, ny);\n    };\n\n    const begin = () => {\n      dragging.current = true;\n      unit = state.current.pointer.unit() || 1;\n      zIndex.set(2);\n      state.current.pointer.lift(id);\n      if (touch) navigator.vibrate?.(8);\n      if (state.current.reduceMotion) scale.jump(1);\n      else animate(scale, LIFT_SCALE, LIFT);\n      follow();\n    };\n\n    const onMove = (ev: PointerEvent) => {\n      if (ev.pointerId !== e.pointerId) return;\n      last = { x: ev.clientX, y: ev.clientY };\n      if (dragging.current) {\n        follow();\n        return;\n      }\n      const moved = Math.hypot(last.x - start.x, last.y - start.y);\n      if (touch && moved > TOUCH_SLOP) finish(false);\n      else if (!touch && moved > MOUSE_THRESHOLD) begin();\n    };\n\n    const finish = (drop: boolean) => {\n      clearTimeout(timer);\n      window.removeEventListener(\"pointermove\", onMove);\n      window.removeEventListener(\"pointerup\", onUp);\n      window.removeEventListener(\"pointercancel\", onUp);\n      cleanup.current = undefined;\n      const wasDragging = dragging.current;\n      dragging.current = false;\n      if (!state.current.reduceMotion) animate(scale, 1, wasDragging ? LIFT : PRESS);\n      if (!wasDragging) return;\n      zIndex.set(1);\n      // Keep the hand's speed, so a flick carries into the landing.\n      settle(drop ? { x: x.getVelocity(), y: y.getVelocity() } : { x: 0, y: 0 });\n      state.current.pointer.drop(id);\n    };\n\n    const onUp = (ev: PointerEvent) => {\n      if (ev.pointerId === e.pointerId) finish(true);\n    };\n\n    // Listening on window, not with pointer capture, so the drag survives\n    // anything React does to the DOM mid-gesture.\n    window.addEventListener(\"pointermove\", onMove);\n    window.addEventListener(\"pointerup\", onUp);\n    window.addEventListener(\"pointercancel\", onUp);\n    cleanup.current = () => finish(false);\n    if (touch) timer = setTimeout(begin, LONG_PRESS);\n  };\n\n  return (\n    <motion.button\n      ref={buttonRef}\n      type=\"button\"\n      tabIndex={focusable ? 0 : -1}\n      aria-label={`${item.label}, position ${index + 1} of ${total}`}\n      aria-describedby={describedBy}\n      aria-pressed={held}\n      onPointerDown={onPointerDown}\n      onContextMenu={(e) => e.preventDefault()}\n      onFocus={onFocus}\n      onBlur={onBlur}\n      onKeyDown={onKeyDown}\n      style={{\n        transform,\n        zIndex,\n        width: TILE_WIDTH,\n        boxShadow: lifted ? LIFTED_SHADOW : \"var(--shadow-raised)\",\n      }}\n      className={cn(\n        \"absolute top-0 left-0 flex aspect-square origin-center touch-manipulation flex-col items-center justify-center gap-2.5 rounded-2xl bg-background text-foreground outline-hidden transition-[box-shadow] duration-200 ease-out select-none [-webkit-touch-callout:none] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n        lifted ? \"cursor-grabbing\" : \"cursor-grab\",\n      )}\n    >\n      <svg\n        viewBox=\"0 0 24 24\"\n        className=\"size-7\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden\n      >\n        {item.icon}\n      </svg>\n      <span className=\"text-sm\">{item.label}</span>\n    </motion.button>\n  );\n}\n\nconst APPS: SortableGridItem[] = [\n  {\n    id: \"a\",\n    label: \"Mail\",\n    icon: (\n      <>\n        <rect x=\"3.5\" y=\"5.5\" width=\"17\" height=\"13\" rx=\"2\" />\n        <path d=\"m4 7 8 6 8-6\" />\n      </>\n    ),\n  },\n  {\n    id: \"b\",\n    label: \"Calendar\",\n    icon: (\n      <>\n        <rect x=\"3.5\" y=\"5\" width=\"17\" height=\"15\" rx=\"2\" />\n        <path d=\"M3.5 10h17M8 3v4M16 3v4\" />\n      </>\n    ),\n  },\n  {\n    id: \"c\",\n    label: \"Photos\",\n    icon: (\n      <>\n        <rect x=\"3.5\" y=\"4.5\" width=\"17\" height=\"15\" rx=\"2\" />\n        <circle cx=\"9\" cy=\"9.5\" r=\"1.5\" />\n        <path d=\"m4 17.5 4.5-4.5 3.5 3.5 2.5-2.5 5.5 5\" />\n      </>\n    ),\n  },\n  {\n    id: \"d\",\n    label: \"Notes\",\n    icon: (\n      <>\n        <path d=\"M6.5 3.5h11a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2h-11a2 2 0 0 1-2-2v-13a2 2 0 0 1 2-2Z\" />\n        <path d=\"M8.5 8.5h7M8.5 12h7M8.5 15.5h4\" />\n      </>\n    ),\n  },\n  {\n    id: \"e\",\n    label: \"Music\",\n    icon: (\n      <>\n        <path d=\"M9.5 17.5v-11l9-2v11\" />\n        <circle cx=\"7.5\" cy=\"17.5\" r=\"2\" />\n        <circle cx=\"16.5\" cy=\"15.5\" r=\"2\" />\n      </>\n    ),\n  },\n  {\n    id: \"f\",\n    label: \"Maps\",\n    icon: (\n      <>\n        <path d=\"m3.5 6.5 5.5-2 6 2 5.5-2v13l-5.5 2-6-2-5.5 2Z\" />\n        <path d=\"M9 4.5v13M15 6.5v13\" />\n      </>\n    ),\n  },\n  {\n    id: \"g\",\n    label: \"Weather\",\n    icon: (\n      <>\n        <path d=\"M7.5 18.5a4 4 0 0 1-.4-8 5.5 5.5 0 0 1 10.6 1.5 3.25 3.25 0 0 1-.2 6.5Z\" />\n      </>\n    ),\n  },\n  {\n    id: \"h\",\n    label: \"Clock\",\n    icon: (\n      <>\n        <circle cx=\"12\" cy=\"12\" r=\"8.5\" />\n        <path d=\"M12 7.5V12l3 2\" />\n      </>\n    ),\n  },\n  {\n    id: \"i\",\n    label: \"Files\",\n    icon: (\n      <path d=\"M3.5 7.5a2 2 0 0 1 2-2h3.6l2 2.2h7.4a2 2 0 0 1 2 2v7.8a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2Z\" />\n    ),\n  },\n];\n\nexport default function SortableGridDemo() {\n  const [apps, setApps] = useState(APPS);\n  return <SortableGrid items={apps} onReorder={setApps} label=\"Apps\" />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/sortable-grid"}