{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kanban-board",
  "type": "registry:ui",
  "title": "Kanban Board",
  "description": "Drag cards within and between columns with a lifted ghost, animated placeholder, WIP limits and full keyboard moves.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/kanban-board.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue } from \"motion/react\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface KanbanCard {\n  /** Stable, unique id across the whole board. */\n  id: string;\n  /** Visible title; also used in screen reader announcements. */\n  title: string;\n  description?: string;\n  /** Small label shown by the default card renderer. */\n  tag?: string;\n}\n\nexport interface KanbanColumn<C extends KanbanCard = KanbanCard> {\n  id: string;\n  title: string;\n  cards: C[];\n  /** Work-in-progress limit. The header shows count / limit and flags the column when it is exceeded. */\n  wipLimit?: number;\n}\n\nexport interface KanbanCardState {\n  /** Rendered inside the lifted drag ghost. */\n  dragging: boolean;\n  /** Picked up with the keyboard. */\n  grabbed: boolean;\n  columnId: string;\n  index: number;\n}\n\nexport interface KanbanBoardProps<C extends KanbanCard = KanbanCard>\n  extends Omit<\n    React.HTMLAttributes<HTMLDivElement>,\n    \"children\" | \"onChange\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\"\n  > {\n  /** Columns and their cards (controlled). */\n  columns: KanbanColumn<C>[];\n  /** Called with the new columns after every move, add or remove. */\n  onChange: (columns: KanbanColumn<C>[]) => void;\n  /** Renders a card's body. The draggable shell is provided. */\n  renderCard?: (card: C, state: KanbanCardState) => React.ReactNode;\n  /** Shows an \"Add card\" button per column. Return a card to append it (or update `columns` yourself). */\n  onAddCard?: (columnId: string) => C | void;\n  /** Shows a remove button on each card. */\n  removable?: boolean;\n  /** Called when a card is removed (after onChange). */\n  onRemoveCard?: (card: C, columnId: string) => void;\n  /** Show the WIP count / limit indicator in column headers. */\n  showWip?: boolean;\n  /** Column width in px. */\n  columnWidth?: number;\n  /** Maximum height of a column's card list before it scrolls, in px. */\n  maxColumnHeight?: number;\n  /** Scroll columns and the board while dragging near their edges. */\n  autoScroll?: boolean;\n  /** Tilt of the lifted ghost, in degrees. */\n  tilt?: number;\n  /** Spring for cards reflowing around the placeholder. */\n  spring?: Spring;\n  /** Accent for the lifted card, placeholder and focus rings. */\n  accent?: string;\n  /** Accessible name of the board. */\n  label?: string;\n}\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst DRAG_THRESHOLD = 4; // px before a press becomes a drag\nconst EDGE = 48; // px from a scroll edge where auto-scroll starts\nconst MAX_SPEED = 14; // px per frame\nconst GAP = 8; // px between cards (matches gap-2)\n\nfunction locate<C extends KanbanCard>(columns: KanbanColumn<C>[], cardId: string) {\n  for (let c = 0; c < columns.length; c++) {\n    const index = columns[c].cards.findIndex((card) => card.id === cardId);\n    if (index >= 0) return { columnIndex: c, index, card: columns[c].cards[index] };\n  }\n  return null;\n}\n\n/** Returns new columns with the card moved to `toColumn` at `toIndex` (clamped). */\nfunction moveCard<C extends KanbanCard>(columns: KanbanColumn<C>[], cardId: string, toColumn: string, toIndex: number) {\n  const from = locate(columns, cardId);\n  if (!from) return columns;\n  const without = columns.map((col, c) => (c === from.columnIndex ? { ...col, cards: col.cards.filter((card) => card.id !== cardId) } : col));\n  return without.map((col) => {\n    if (col.id !== toColumn) return col;\n    const cards = col.cards.slice();\n    cards.splice(Math.max(0, Math.min(cards.length, toIndex)), 0, from.card);\n    return { ...col, cards };\n  });\n}\n\nfunction scrollSpeed(pos: number, start: number, end: number) {\n  if (pos < start + EDGE) return -MAX_SPEED * Math.min(1, (start + EDGE - pos) / EDGE);\n  if (pos > end - EDGE) return MAX_SPEED * Math.min(1, (pos - (end - EDGE)) / EDGE);\n  return 0;\n}\n\ninterface DragState {\n  cardId: string;\n  fromColumn: string;\n  fromIndex: number;\n  toColumn: string;\n  toIndex: number;\n  width: number;\n  height: number;\n  dropping: boolean;\n}\n\n/**\n * A Kanban board with pointer and keyboard drag and drop. Pointer drags lift a tilted ghost into the top\n * layer while an animated placeholder shows where the card will land; columns auto-scroll near their edges.\n * From the keyboard, Space picks a card up, arrows move it within and between columns, Space drops and\n * Escape cancels, with every step announced in a polite live region.\n */\nexport function KanbanBoard<C extends KanbanCard = KanbanCard>({\n  columns,\n  onChange,\n  renderCard,\n  onAddCard,\n  removable = false,\n  onRemoveCard,\n  showWip = true,\n  columnWidth = 272,\n  maxColumnHeight = 420,\n  autoScroll = true,\n  tilt = 2,\n  spring = { stiffness: 520, damping: 42, mass: 1 },\n  accent = \"#FF7448\",\n  label = \"Board\",\n  className = \"\",\n  style,\n  ...props\n}: KanbanBoardProps<C>) {\n  const uid = React.useId();\n  const instructionsId = `${uid}-instructions`;\n  const reduceMotion = usePrefersReducedMotion();\n  const [drag, setDrag] = React.useState<DragState | null>(null);\n  const [grabbed, setGrabbed] = React.useState<{ cardId: string; snapshot: KanbanColumn<C>[] } | null>(null);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  const boardRef = React.useRef<HTMLDivElement>(null);\n  const columnRefs = React.useRef(new Map<string, HTMLElement>());\n  const listRefs = React.useRef(new Map<string, HTMLUListElement>());\n  const cardRefs = React.useRef(new Map<string, HTMLLIElement>());\n  const handleRefs = React.useRef(new Map<string, HTMLDivElement>());\n  const refocusRef = React.useRef<string | null>(null);\n  const liveRef = React.useRef<{ drag: DragState | null; x: number; y: number; frame: number; cleanup: (() => void) | null }>({\n    drag: null,\n    x: 0,\n    y: 0,\n    frame: 0,\n    cleanup: null,\n  });\n  const ghostX = useMotionValue(0);\n  const ghostY = useMotionValue(0);\n\n  // While dragging, cards render from the previewed order so the placeholder sits where the card will land.\n  const shown = drag ? moveCard(columns, drag.cardId, drag.toColumn, drag.toIndex) : columns;\n  const titleOf = (id: string) => locate(columns, id)?.card.title ?? \"card\";\n  const columnTitle = (id: string) => columns.find((col) => col.id === id)?.title ?? id;\n  const positionText = (cols: KanbanColumn<C>[], cardId: string) => {\n    const at = locate(cols, cardId);\n    if (!at) return \"\";\n    const col = cols[at.columnIndex];\n    return `${col.title}, position ${at.index + 1} of ${col.cards.length}`;\n  };\n\n  /* ------------------------------ focus helpers ------------------------------ */\n\n  /** Scrolls the card's column list and the board (never the page) so the card is visible. */\n  const revealCard = React.useCallback((cardId: string) => {\n    const card = cardRefs.current.get(cardId);\n    const list = card?.parentElement;\n    if (!card || !list) return;\n    const top = card.offsetTop;\n    const bottom = top + card.offsetHeight;\n    if (top < list.scrollTop) list.scrollTop = top - GAP;\n    else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight + GAP;\n    const board = boardRef.current;\n    const column = card.closest<HTMLElement>(\"[data-kanban-column]\");\n    if (board && column) {\n      const b = board.getBoundingClientRect();\n      const r = column.getBoundingClientRect();\n      if (r.left < b.left) board.scrollLeft -= b.left - r.left + 12;\n      else if (r.right > b.right) board.scrollLeft += r.right - b.right + 12;\n    }\n  }, []);\n\n  // A moved card can re-mount in another column; put focus back on it after each change.\n  React.useEffect(() => {\n    const id = refocusRef.current;\n    if (!id) return;\n    refocusRef.current = null;\n    const handle = handleRefs.current.get(id);\n    if (handle && document.activeElement !== handle) handle.focus({ preventScroll: true });\n    revealCard(id);\n  }, [columns, revealCard]);\n\n  // Stop listeners and loops if the board unmounts mid-drag.\n  React.useEffect(() => {\n    const live = liveRef.current;\n    return () => {\n      cancelAnimationFrame(live.frame);\n      live.cleanup?.();\n    };\n  }, []);\n\n  /* -------------------------------- pointer -------------------------------- */\n\n  const updateDrag = (next: DragState | null) => {\n    liveRef.current.drag = next;\n    setDrag(next);\n  };\n\n  /** Works out which column and slot the pointer is over, ignoring the placeholder's own height. */\n  const computeTarget = () => {\n    const live = liveRef.current;\n    const d = live.drag;\n    if (!d || d.dropping) return;\n    let toColumn = d.toColumn;\n    let best = Infinity;\n    columnRefs.current.forEach((el, id) => {\n      const r = el.getBoundingClientRect();\n      const dist = live.x < r.left ? r.left - live.x : live.x > r.right ? live.x - r.right : 0;\n      if (dist < best) {\n        best = dist;\n        toColumn = id;\n      }\n    });\n    const list = listRefs.current.get(toColumn);\n    if (!list) return;\n    const y = live.y - list.getBoundingClientRect().top + list.scrollTop;\n    let toIndex = 0;\n    let shift = 0;\n    for (const el of Array.from(list.querySelectorAll<HTMLElement>(\":scope > [data-card-id]\"))) {\n      if (el.dataset.cardId === d.cardId) {\n        shift = el.offsetHeight + GAP;\n        continue;\n      }\n      if (el.offsetTop - shift + el.offsetHeight / 2 < y) toIndex++;\n    }\n    if (toColumn !== d.toColumn || toIndex !== d.toIndex) updateDrag({ ...d, toColumn, toIndex });\n  };\n\n  const autoScrollTick = () => {\n    const live = liveRef.current;\n    const d = live.drag;\n    if (!d || d.dropping) return;\n    let scrolled = false;\n    const list = listRefs.current.get(d.toColumn);\n    if (list && list.scrollHeight > list.clientHeight) {\n      const r = list.getBoundingClientRect();\n      const v = live.x >= r.left && live.x <= r.right ? scrollSpeed(live.y, r.top, r.bottom) : 0;\n      if (v) {\n        list.scrollTop += v;\n        scrolled = true;\n      }\n    }\n    const board = boardRef.current;\n    if (board && board.scrollWidth > board.clientWidth) {\n      const r = board.getBoundingClientRect();\n      const v = scrollSpeed(live.x, r.left, r.right);\n      if (v) {\n        board.scrollLeft += v;\n        scrolled = true;\n      }\n    }\n    if (scrolled) computeTarget();\n    live.frame = requestAnimationFrame(autoScrollTick);\n  };\n\n  const finishDrop = () => {\n    liveRef.current.drag = null;\n    setDrag(null);\n  };\n\n  /** Flies the ghost onto the card's resting place, then removes it. */\n  const settleGhost = (cardId: string) => {\n    requestAnimationFrame(() => {\n      const target = cardRefs.current.get(cardId)?.getBoundingClientRect();\n      if (!target || reduceMotion) {\n        finishDrop();\n        return;\n      }\n      const settle = { type: \"spring\" as const, stiffness: 600, damping: 45 };\n      animate(ghostX, target.left, settle);\n      animate(ghostY, target.top, settle).then(finishDrop);\n    });\n  };\n\n  const endPointerDrag = (cancelled: boolean) => {\n    const live = liveRef.current;\n    cancelAnimationFrame(live.frame);\n    live.cleanup?.();\n    live.cleanup = null;\n    const d = live.drag;\n    if (!d) return;\n    const name = titleOf(d.cardId);\n    if (cancelled) {\n      updateDrag({ ...d, toColumn: d.fromColumn, toIndex: d.fromIndex, dropping: true });\n      setAnnouncement(`Move cancelled. ${name} returned to ${positionText(columns, d.cardId)}.`);\n    } else {\n      const next = moveCard(columns, d.cardId, d.toColumn, d.toIndex);\n      updateDrag({ ...d, dropping: true });\n      if (d.toColumn !== d.fromColumn || d.toIndex !== d.fromIndex) {\n        onChange(next);\n        setAnnouncement(`Moved ${name} to ${positionText(next, d.cardId)}.`);\n      }\n    }\n    settleGhost(d.cardId);\n  };\n\n  const onCardPointerDown = (event: React.PointerEvent<HTMLDivElement>, card: C, columnId: string, index: number) => {\n    if (event.button !== 0 || grabbed || liveRef.current.drag) return;\n    // On touch, only the grip starts a drag so the column can still be scrolled with a finger.\n    if (event.pointerType === \"touch\" && !(event.target as Element).closest(\"[data-kanban-grip]\")) return;\n    const el = cardRefs.current.get(card.id);\n    if (!el) return;\n    const startX = event.clientX;\n    const startY = event.clientY;\n    const pointerId = event.pointerId;\n    let started = false;\n    let offsetX = 0;\n    let offsetY = 0;\n\n    const onMove = (e: PointerEvent) => {\n      if (e.pointerId !== pointerId) return;\n      const live = liveRef.current;\n      live.x = e.clientX;\n      live.y = e.clientY;\n      if (!started) {\n        if (Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD) return;\n        started = true;\n        const rect = el.getBoundingClientRect();\n        offsetX = startX - rect.left;\n        offsetY = startY - rect.top;\n        ghostX.set(rect.left);\n        ghostY.set(rect.top);\n        window.getSelection()?.removeAllRanges();\n        updateDrag({\n          cardId: card.id,\n          fromColumn: columnId,\n          fromIndex: index,\n          toColumn: columnId,\n          toIndex: index,\n          width: rect.width,\n          height: rect.height,\n          dropping: false,\n        });\n        if (autoScroll) live.frame = requestAnimationFrame(autoScrollTick);\n      }\n      e.preventDefault();\n      ghostX.set(e.clientX - offsetX);\n      ghostY.set(e.clientY - offsetY);\n      computeTarget();\n    };\n    const onUp = (e: PointerEvent) => {\n      if (e.pointerId !== pointerId) return;\n      if (started) endPointerDrag(false);\n      else cleanup();\n    };\n    const onCancel = (e: PointerEvent) => {\n      if (e.pointerId !== pointerId) return;\n      if (started) endPointerDrag(true);\n      else cleanup();\n    };\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\" && started) {\n        e.preventDefault();\n        e.stopPropagation();\n        endPointerDrag(true);\n      }\n    };\n    // Stop the browser from scrolling or selecting text once a touch drag has begun on the grip.\n    const onTouchMove = (e: TouchEvent) => {\n      if (started) e.preventDefault();\n    };\n    const cleanup = () => {\n      window.removeEventListener(\"pointermove\", onMove);\n      window.removeEventListener(\"pointerup\", onUp);\n      window.removeEventListener(\"pointercancel\", onCancel);\n      window.removeEventListener(\"keydown\", onKey, true);\n      window.removeEventListener(\"touchmove\", onTouchMove);\n      if (liveRef.current.cleanup === cleanup) liveRef.current.cleanup = null;\n    };\n    // Window listeners: the card may re-mount in another column mid-drag, which would drop pointer capture.\n    window.addEventListener(\"pointermove\", onMove, { passive: false });\n    window.addEventListener(\"pointerup\", onUp);\n    window.addEventListener(\"pointercancel\", onCancel);\n    window.addEventListener(\"keydown\", onKey, true);\n    window.addEventListener(\"touchmove\", onTouchMove, { passive: false });\n    liveRef.current.cleanup = cleanup;\n  };\n\n  /* -------------------------------- keyboard -------------------------------- */\n\n  const commitKeyboardMove = (card: C, toColumnIndex: number, toIndex: number) => {\n    const target = columns[toColumnIndex];\n    const from = locate(columns, card.id);\n    if (!target || !from) return;\n    const max = target.cards.length - (from.columnIndex === toColumnIndex ? 1 : 0);\n    const index = Math.max(0, Math.min(max, toIndex));\n    if (from.columnIndex === toColumnIndex && index === from.index) return;\n    const next = moveCard(columns, card.id, target.id, index);\n    refocusRef.current = card.id;\n    onChange(next);\n    setAnnouncement(`Moved ${card.title} to ${positionText(next, card.id)}.`);\n  };\n\n  const onCardKeyDown = (event: React.KeyboardEvent<HTMLDivElement>, card: C) => {\n    if (drag) return;\n    const at = locate(columns, card.id);\n    if (!at) return;\n    const isGrabbed = grabbed?.cardId === card.id;\n    if (!isGrabbed) {\n      if (event.key === \" \" || event.key === \"Enter\") {\n        event.preventDefault();\n        setGrabbed({ cardId: card.id, snapshot: columns });\n        setAnnouncement(\n          `Picked up ${card.title}, ${positionText(columns, card.id)}. Use the arrow keys to move it, Space to drop, Escape to cancel.`,\n        );\n      }\n      return;\n    }\n    switch (event.key) {\n      case \"ArrowUp\":\n        event.preventDefault();\n        commitKeyboardMove(card, at.columnIndex, at.index - 1);\n        break;\n      case \"ArrowDown\":\n        event.preventDefault();\n        commitKeyboardMove(card, at.columnIndex, at.index + 1);\n        break;\n      case \"ArrowLeft\":\n        event.preventDefault();\n        if (at.columnIndex > 0) commitKeyboardMove(card, at.columnIndex - 1, at.index);\n        break;\n      case \"ArrowRight\":\n        event.preventDefault();\n        if (at.columnIndex < columns.length - 1) commitKeyboardMove(card, at.columnIndex + 1, at.index);\n        break;\n      case \"Home\":\n        event.preventDefault();\n        commitKeyboardMove(card, at.columnIndex, 0);\n        break;\n      case \"End\":\n        event.preventDefault();\n        commitKeyboardMove(card, at.columnIndex, Number.MAX_SAFE_INTEGER);\n        break;\n      case \" \":\n      case \"Enter\":\n        event.preventDefault();\n        setGrabbed(null);\n        setAnnouncement(`Dropped ${card.title} in ${positionText(columns, card.id)}.`);\n        break;\n      case \"Escape\": {\n        event.preventDefault();\n        // Keep Escape from also closing a surrounding dialog.\n        event.stopPropagation();\n        const snapshot = grabbed?.snapshot;\n        setGrabbed(null);\n        if (snapshot) {\n          refocusRef.current = card.id;\n          onChange(snapshot);\n          setAnnouncement(`Move cancelled. ${card.title} returned to ${positionText(snapshot, card.id)}.`);\n        }\n        break;\n      }\n      case \"Tab\":\n        setGrabbed(null);\n        setAnnouncement(`Dropped ${card.title} in ${positionText(columns, card.id)}.`);\n        break;\n    }\n  };\n\n  /* ------------------------------ add / remove ------------------------------ */\n\n  const addCard = (columnId: string) => {\n    const created = onAddCard?.(columnId);\n    if (!created) return;\n    const next = columns.map((col) => (col.id === columnId ? { ...col, cards: [...col.cards, created] } : col));\n    refocusRef.current = created.id;\n    onChange(next);\n    setAnnouncement(`Added ${created.title} to ${columnTitle(columnId)}.`);\n  };\n\n  const removeCard = (card: C, columnId: string) => {\n    const at = locate(columns, card.id);\n    if (!at) return;\n    const cards = columns[at.columnIndex].cards;\n    const neighbour = cards[at.index + 1] ?? cards[at.index - 1];\n    const next = columns.map((col) => (col.id === columnId ? { ...col, cards: col.cards.filter((c) => c.id !== card.id) } : col));\n    if (neighbour) refocusRef.current = neighbour.id;\n    onChange(next);\n    onRemoveCard?.(card, columnId);\n    setAnnouncement(`Removed ${card.title} from ${columnTitle(columnId)}.`);\n  };\n\n  /* --------------------------------- render --------------------------------- */\n\n  const layoutTransition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n  const draggedCard = drag ? locate(columns, drag.cardId)?.card : undefined;\n\n  const cardBody = (card: C, state: KanbanCardState) =>\n    renderCard ? (\n      renderCard(card, state)\n    ) : (\n      <>\n        {card.tag && (\n          <span className=\"mb-1.5 inline-block rounded-[5px] border border-zinc-900/10 px-1.5 py-px font-mono text-[9px] uppercase tracking-[0.12em] text-zinc-500 dark:border-white/10 dark:text-zinc-400\">\n            {card.tag}\n          </span>\n        )}\n        <span className=\"block text-[13px] font-medium leading-snug tracking-tight\">{card.title}</span>\n        {card.description && <span className=\"mt-1 block text-[12px] leading-snug text-zinc-500 dark:text-zinc-400\">{card.description}</span>}\n      </>\n    );\n\n  return (\n    <div\n      role=\"region\"\n      aria-label={label}\n      className={`relative w-full min-w-0 text-zinc-900 dark:text-zinc-100 ${className}`}\n      style={{ [\"--kb-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <p id={instructionsId} className=\"sr-only\">\n        Press Space or Enter to pick up a card. While it is picked up, use the up and down arrows to move it within a column,\n        left and right arrows to move it to another column, Space or Enter to drop it, and Escape to cancel.\n      </p>\n      <motion.div\n        ref={boardRef}\n        layoutScroll\n        className={`flex items-start gap-3 overflow-x-auto overscroll-x-contain pb-2 ${drag ? \"cursor-grabbing select-none\" : \"\"}`}\n      >\n        {shown.map((column) => {\n          const count = column.cards.length;\n          const limit = column.wipLimit;\n          const over = limit !== undefined && count > limit;\n          const headingId = `${uid}-col-${column.id}`;\n          return (\n            <section\n              key={column.id}\n              ref={(node) => {\n                if (node) columnRefs.current.set(column.id, node);\n                else columnRefs.current.delete(column.id);\n              }}\n              data-kanban-column={column.id}\n              aria-labelledby={headingId}\n              className={`flex shrink-0 flex-col rounded-2xl border bg-zinc-50 transition-colors dark:bg-zinc-900/70 ${\n                drag && drag.toColumn === column.id && !drag.dropping\n                  ? \"border-[color-mix(in_oklab,var(--kb-accent)_45%,transparent)]\"\n                  : \"border-zinc-900/[0.08] dark:border-white/[0.08]\"\n              }`}\n              style={{ width: columnWidth }}\n            >\n              <header className=\"px-3 pb-2 pt-3\">\n                <div className=\"flex items-center justify-between gap-2\">\n                  <h3 id={headingId} className=\"truncate text-[13px] font-semibold tracking-tight\">\n                    {column.title}\n                  </h3>\n                  {showWip && (\n                    <span\n                      className={`shrink-0 rounded-md px-1.5 py-0.5 font-mono text-[10px] tabular-nums tracking-[0.06em] ${\n                        over\n                          ? \"bg-red-500/10 text-red-600 dark:text-red-400\"\n                          : \"bg-zinc-900/[0.05] text-zinc-500 dark:bg-white/[0.06] dark:text-zinc-400\"\n                      }`}\n                    >\n                      {count}\n                      {limit !== undefined && ` / ${limit}`}\n                      <span className=\"sr-only\">\n                        {\" \"}\n                        {count === 1 ? \"card\" : \"cards\"}\n                        {limit !== undefined && (over ? \", over the work-in-progress limit\" : \", work-in-progress limit\")}\n                      </span>\n                    </span>\n                  )}\n                </div>\n                {showWip && limit !== undefined && (\n                  <div aria-hidden=\"true\" className=\"mt-2 h-[3px] overflow-hidden rounded-full bg-zinc-900/[0.06] dark:bg-white/[0.06]\">\n                    <div\n                      className={`h-full origin-left rounded-full transition-[scale,background-color] duration-300 ${over ? \"bg-red-500\" : \"bg-[var(--kb-accent)]\"}`}\n                      style={{ scale: `${Math.min(1, count / Math.max(1, limit))} 1` }}\n                    />\n                  </div>\n                )}\n              </header>\n\n              <motion.ul\n                ref={(node: HTMLUListElement | null) => {\n                  if (node) listRefs.current.set(column.id, node);\n                  else listRefs.current.delete(column.id);\n                }}\n                layoutScroll\n                aria-labelledby={headingId}\n                className=\"relative flex min-h-16 flex-col gap-2 overflow-y-auto overscroll-y-contain px-2 pb-2\"\n                style={{ maxHeight: maxColumnHeight }}\n              >\n                {column.cards.map((card, index) => {\n                  const isPlaceholder = drag?.cardId === card.id;\n                  const isGrabbed = grabbed?.cardId === card.id;\n                  return (\n                    <motion.li\n                      key={card.id}\n                      ref={(node: HTMLLIElement | null) => {\n                        if (node) cardRefs.current.set(card.id, node);\n                        else cardRefs.current.delete(card.id);\n                      }}\n                      data-card-id={card.id}\n                      // layoutId lets a card glide between columns; useId keeps it unique per board.\n                      layoutId={`${uid}-${card.id}`}\n                      layout={!reduceMotion}\n                      transition={layoutTransition}\n                      className=\"group/card relative shrink-0\"\n                    >\n                      {isPlaceholder ? (\n                        <div\n                          aria-hidden=\"true\"\n                          className=\"rounded-xl border border-dashed border-[color-mix(in_oklab,var(--kb-accent)_60%,transparent)] bg-[color-mix(in_oklab,var(--kb-accent)_8%,transparent)]\"\n                          style={{ height: drag?.height }}\n                        />\n                      ) : (\n                        <>\n                          <div\n                            ref={(node) => {\n                              if (node) handleRefs.current.set(card.id, node);\n                              else handleRefs.current.delete(card.id);\n                            }}\n                            role=\"button\"\n                            tabIndex={0}\n                            aria-roledescription=\"draggable card\"\n                            aria-describedby={instructionsId}\n                            aria-pressed={isGrabbed}\n                            onPointerDown={(event) => onCardPointerDown(event, card, column.id, index)}\n                            onKeyDown={(event) => onCardKeyDown(event, card)}\n                            onBlur={() => {\n                              // Focus leaving a lifted card (other than our own refocus after a move) drops it in place.\n                              if (isGrabbed && refocusRef.current !== card.id) {\n                                setGrabbed(null);\n                                setAnnouncement(`Dropped ${card.title} in ${positionText(columns, card.id)}.`);\n                              }\n                            }}\n                            className={`relative block cursor-grab select-none rounded-xl border bg-white px-3 py-2.5 text-left outline-hidden transition-[border-color,box-shadow,scale] duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--kb-accent)] dark:bg-zinc-950 ${\n                              removable ? \"pr-9\" : \"\"\n                            } ${\n                              isGrabbed\n                                ? \"scale-[1.02] border-[var(--kb-accent)] shadow-[0_14px_30px_-14px_rgba(0,0,0,0.35)] dark:shadow-[0_14px_30px_-10px_rgba(0,0,0,0.9)]\"\n                                : \"border-zinc-900/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] hover:border-zinc-900/20 dark:border-white/[0.08] dark:hover:border-white/20\"\n                            }`}\n                          >\n                            {cardBody(card, { dragging: false, grabbed: isGrabbed, columnId: column.id, index })}\n                            <span\n                              data-kanban-grip=\"\"\n                              aria-hidden=\"true\"\n                              className=\"absolute bottom-1.5 right-1.5 flex h-6 w-5 touch-none items-center justify-center rounded-md text-zinc-300 dark:text-zinc-600\"\n                            >\n                              <svg viewBox=\"0 0 10 16\" className=\"h-3 w-2\" fill=\"currentColor\">\n                                {[3, 8, 13].map((y) => (\n                                  <React.Fragment key={y}>\n                                    <circle cx=\"2.5\" cy={y} r=\"1.25\" />\n                                    <circle cx=\"7.5\" cy={y} r=\"1.25\" />\n                                  </React.Fragment>\n                                ))}\n                              </svg>\n                            </span>\n                          </div>\n                          {removable && (\n                            <button\n                              type=\"button\"\n                              aria-label={`Remove ${card.title}`}\n                              onClick={() => removeCard(card, column.id)}\n                              className=\"absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-md text-zinc-400 opacity-0 outline-hidden transition-[opacity,background-color,color] hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--kb-accent)] group-hover/card:opacity-100 group-focus-within/card:opacity-100 dark:text-zinc-500 dark:hover:bg-white/[0.08] dark:hover:text-zinc-100 [@media(hover:none)]:opacity-100\"\n                            >\n                              <svg viewBox=\"0 0 12 12\" className=\"size-2.5\" fill=\"none\" aria-hidden=\"true\">\n                                <path d=\"M3 3l6 6M9 3l-6 6\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" />\n                              </svg>\n                            </button>\n                          )}\n                        </>\n                      )}\n                    </motion.li>\n                  );\n                })}\n                {column.cards.length === 0 && (\n                  <li\n                    aria-hidden=\"true\"\n                    className=\"flex h-16 items-center justify-center rounded-xl border border-dashed border-zinc-900/10 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:border-white/10 dark:text-zinc-500\"\n                  >\n                    Drop here\n                  </li>\n                )}\n              </motion.ul>\n\n              {onAddCard && (\n                <div className=\"px-2 pb-2\">\n                  <button\n                    type=\"button\"\n                    onClick={() => addCard(column.id)}\n                    className=\"flex h-8 w-full items-center gap-1.5 rounded-lg px-2 text-[12px] font-medium text-zinc-500 outline-hidden transition-colors hover:bg-zinc-900/[0.05] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--kb-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.06] dark:hover:text-zinc-100\"\n                  >\n                    <svg viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" aria-hidden=\"true\">\n                      <path d=\"M6 2v8M2 6h8\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" />\n                    </svg>\n                    Add card<span className=\"sr-only\"> to {column.title}</span>\n                  </button>\n                </div>\n              )}\n            </section>\n          );\n        })}\n      </motion.div>\n\n      {drag && draggedCard && (\n        <div\n          ref={(node) => {\n            // Top layer: never clipped by the board and immune to transformed ancestors.\n            if (node && typeof node.showPopover === \"function\" && !node.matches(\":popover-open\")) {\n              try {\n                node.showPopover();\n              } catch {\n                // Unsupported: position: fixed still applies.\n              }\n            }\n          }}\n          popover=\"manual\"\n          aria-hidden=\"true\"\n          className=\"pointer-events-none fixed inset-0 m-0 h-full max-h-none w-full max-w-none overflow-visible border-0 bg-transparent p-0\"\n        >\n          <motion.div\n            className=\"absolute left-0 top-0\"\n            style={{ x: ghostX, y: ghostY, width: drag.width }}\n          >\n            <motion.div\n              initial={reduceMotion ? false : { rotate: 0, scale: 1 }}\n              animate={drag.dropping || reduceMotion ? { rotate: 0, scale: 1 } : { rotate: tilt, scale: 1.03 }}\n              transition={{ type: \"spring\", stiffness: 500, damping: 30 }}\n              className=\"cursor-grabbing rounded-xl border border-[var(--kb-accent)] bg-white px-3 py-2.5 text-zinc-900 shadow-[0_24px_48px_-18px_rgba(0,0,0,0.45),0_4px_10px_rgba(0,0,0,0.08)] dark:bg-zinc-950 dark:text-zinc-100 dark:shadow-[0_24px_48px_-12px_rgba(0,0,0,0.9)]\"\n            >\n              {cardBody(draggedCard, { dragging: true, grabbed: false, columnId: drag.toColumn, index: drag.toIndex })}\n            </motion.div>\n          </motion.div>\n        </div>\n      )}\n\n      <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/kanban-board"
}
