{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kanban-board",
  "title": "Kanban Board",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A responsive task board with pointer drag and keyboard movement between columns.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json",
    "https://ui.ballmac.com/r/direction.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/kanban-board.tsx",
      "content": "// Ballmac UI: Kanban Board. https://ui.ballmac.com/components/kanban-board\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowLeft, ArrowRight, GripVertical } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\nimport { useDirection } from \"@/lib/ballmac/direction\"\n\ntype KanbanCard = {\n  /** Stable card ID. */ id: string\n  /** Card title. */ title: string\n  /** Optional supporting detail. */ description?: string\n  /** Optional short category. */ label?: string\n}\ntype KanbanColumn = {\n  /** Stable column ID. */ id: string\n  /** Column title. */ title: string\n  /** Cards in display order. */ cards: KanbanCard[]\n}\ntype KanbanBoardProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\"> & {\n  /** Controlled board state. */\n  columns?: KanbanColumn[]\n  /** Initial board state. */\n  defaultColumns?: KanbanColumn[]\n  /** Called after a card moves. */\n  onColumnsChange?: (columns: KanbanColumn[]) => void\n  /** Accessible name for the board. */\n  label?: string\n}\nfunction KanbanBoard({\n  className,\n  columns,\n  defaultColumns = [],\n  onColumnsChange,\n  label,\n  ...props\n}: KanbanBoardProps) {\n  const dir = useDirection()\n  const msg = useMessages()\n  label ??= msg(\"kanban-board.label\", \"Task board\")\n  const [internal, setInternal] = React.useState(defaultColumns)\n  const [dragging, setDragging] = React.useState<string | null>(null)\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  const current = columns ?? internal\n  const cardRefs = React.useRef(new Map<string, HTMLElement>())\n  function move(cardId: string, targetIndex: number, offset?: number) {\n    const sourceIndex = current.findIndex((column) =>\n      column.cards.some((card) => card.id === cardId),\n    )\n    if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= current.length)\n      return\n    const sourceCardIndex = current[sourceIndex].cards.findIndex(\n      (card) => card.id === cardId,\n    )\n    const next = current.map((column) => ({\n      ...column,\n      cards: [...column.cards],\n    }))\n    const [card] = next[sourceIndex].cards.splice(sourceCardIndex, 1)\n    const insertion =\n      sourceIndex === targetIndex\n        ? Math.max(\n            0,\n            Math.min(\n              next[targetIndex].cards.length,\n              sourceCardIndex + (offset ?? 0),\n            ),\n          )\n        : next[targetIndex].cards.length\n    next[targetIndex].cards.splice(insertion, 0, card)\n    if (columns === undefined) setInternal(next)\n    onColumnsChange?.(next)\n    setAnnouncement(`${card.title} moved to ${next[targetIndex].title}`)\n    if (typeof requestAnimationFrame === \"function\")\n      requestAnimationFrame(() => cardRefs.current.get(cardId)?.focus())\n    else cardRefs.current.get(cardId)?.focus()\n  }\n  return (\n    <div\n      data-slot=\"kanban-board\"\n      role=\"region\"\n      aria-label={label}\n      tabIndex={0}\n      className={cn(\n        \"max-w-full overflow-x-auto rounded-xl border border-border bg-muted/30 p-3 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n        className,\n      )}\n      {...props}\n    >\n      <div className=\"flex min-w-max gap-3\">\n        {current.map((column, columnIndex) => (\n          <section\n            key={column.id}\n            data-slot=\"kanban-column\"\n            aria-label={column.title}\n            onDragOver={(event) => event.preventDefault()}\n            onDrop={(event) => {\n              event.preventDefault()\n              if (dragging) move(dragging, columnIndex)\n              setDragging(null)\n            }}\n            className=\"bg-card w-60 shrink-0 rounded-lg border border-border p-3 shadow-sm\"\n          >\n            <div className=\"mb-3 flex items-center justify-between gap-2\">\n              <h3 className=\"text-sm font-semibold\">{column.title}</h3>\n              <span className=\"bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs tabular-nums\">\n                {column.cards.length}\n              </span>\n            </div>\n            <div className=\"flex min-h-16 flex-col gap-2\">\n              {column.cards.map((card) => (\n                <article\n                  key={card.id}\n                  ref={(element) => {\n                    if (element) cardRefs.current.set(card.id, element)\n                    else cardRefs.current.delete(card.id)\n                  }}\n                  data-slot=\"kanban-card\"\n                  draggable\n                  tabIndex={0}\n                  aria-label={msg(\"kanban-board.useAltAndArrowKeys\", \"{title}, {column}. Use Alt and arrow keys to move.\", { title: card.title, column: column.title })}\n                  onDragStart={(event) => {\n                    setDragging(card.id)\n                    event.dataTransfer.effectAllowed = \"move\"\n                    event.dataTransfer.setData(\"text/plain\", card.id)\n                  }}\n                  onDragEnd={() => setDragging(null)}\n                  onKeyDown={(event) => {\n                    if (!event.altKey) return\n                    if (event.key === (dir === \"rtl\" ? \"ArrowRight\" : \"ArrowLeft\"))\n                      move(card.id, columnIndex - 1)\n                    else if (event.key === (dir === \"rtl\" ? \"ArrowLeft\" : \"ArrowRight\"))\n                      move(card.id, columnIndex + 1)\n                    else if (event.key === \"ArrowUp\")\n                      move(card.id, columnIndex, -1)\n                    else if (event.key === \"ArrowDown\")\n                      move(card.id, columnIndex, 1)\n                    else return\n                    event.preventDefault()\n                  }}\n                  className=\"group/kanban-card bg-background rounded-md border border-border p-3 shadow-sm outline-none transition-[border-color,box-shadow] duration-150 hover:border-ring/50 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\"\n                >\n                  <div className=\"flex items-start gap-2\">\n                    <GripVertical\n                      aria-hidden=\"true\"\n                      className=\"text-muted-foreground mt-0.5 size-4 shrink-0\"\n                    />\n                    <div className=\"min-w-0 flex-1\">\n                      <p className=\"text-sm font-medium leading-5\">\n                        {card.title}\n                      </p>\n                      {card.description && (\n                        <p className=\"text-muted-foreground mt-1 text-xs leading-relaxed\">\n                          {card.description}\n                        </p>\n                      )}\n                      {card.label && (\n                        <span className=\"bg-secondary text-secondary-foreground mt-2 inline-block rounded-full px-2 py-0.5 text-xs\">\n                          {card.label}\n                        </span>\n                      )}\n                    </div>\n                  </div>\n                  <div className=\"mt-3 flex items-center justify-end gap-1 border-t border-border pt-2\">\n                    <button\n                      type=\"button\"\n                      aria-label={msg(\"kanban-board.moveToPreviousColumn\", \"Move {title} to previous column\", { title: card.title })}\n                      disabled={columnIndex === 0}\n                      onClick={() => move(card.id, columnIndex - 1)}\n                      className=\"text-muted-foreground hover:bg-accent hover:text-foreground flex size-7 items-center justify-center rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-40\"\n                    >\n                      <ArrowLeft aria-hidden=\"true\" className=\"size-3.5 rtl:rotate-180\" />\n                    </button>\n                    <button\n                      type=\"button\"\n                      aria-label={msg(\"kanban-board.moveToNextColumn\", \"Move {title} to next column\", { title: card.title })}\n                      disabled={columnIndex === current.length - 1}\n                      onClick={() => move(card.id, columnIndex + 1)}\n                      className=\"text-muted-foreground hover:bg-accent hover:text-foreground flex size-7 items-center justify-center rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-40\"\n                    >\n                      <ArrowRight aria-hidden=\"true\" className=\"size-3.5 rtl:rotate-180\" />\n                    </button>\n                  </div>\n                </article>\n              ))}\n            </div>\n          </section>\n        ))}\n      </div>\n      <p className=\"text-muted-foreground mt-3 text-xs\">\n        Drag a card or use its arrow buttons. Alt + arrow keys also move a\n        focused card.\n      </p>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  )\n}\nexport {\n  KanbanBoard,\n  type KanbanBoardProps,\n  type KanbanColumn,\n  type KanbanCard,\n}\n",
      "type": "registry:ui",
      "target": "@components/ballmac/kanban-board.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "kanban",
      "tasks",
      "board"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "A responsive task board with pointer drag and keyboard movement between columns.",
      "whenToUse": [
        "Organize tasks across workflow stages"
      ],
      "whenNotToUse": [
        "Use a table for dense sortable records"
      ],
      "composesWith": [
        "card"
      ],
      "a11y": [
        {
          "keys": "Tab / Alt + Arrow keys",
          "action": "Focuses and moves cards; buttons also move between columns"
        }
      ],
      "customization": [
        "Controlled and uncontrolled state",
        "Content and token-based className styling"
      ]
    },
    "examples": [
      "kanban-board-demo",
      "kanban-board-states"
    ],
    "url": "https://ui.ballmac.com/components/kanban-board"
  },
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}