{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kanban-1",
  "title": "Kanban 1: project board with drag and keyboard moves",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A project board: columns with counts, rich task cards (tags, priority, due date, comments, assignee), drag and drop with a drop indicator, keyboard pick-up and move with announcements, a Move-to menu, quick add and a person filter.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/avatar.json",
    "https://ui.ballmac.com/r/badge.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/dropdown-menu.json",
    "https://ui.ballmac.com/r/input.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/kanban-1/kanban-1.tsx",
      "content": "// Ballmac UI: Kanban 1. https://ui.ballmac.com/blocks/kanban-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { CalendarDays, MessageSquare, MoreHorizontal, Plus, X } from \"lucide-react\"\n\nimport { Avatar, AvatarFallback } from \"@/components/ballmac/avatar\"\nimport { Badge } from \"@/components/ballmac/badge\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from \"@/components/ballmac/dropdown-menu\"\nimport { Input } from \"@/components/ballmac/input\"\nimport { cn } from \"@/lib/utils\"\n\ntype Kanban1Task = {\n  id: string\n  title: string\n  /** Short labels, e.g. \"Design\". */\n  tags?: string[]\n  priority?: \"low\" | \"medium\" | \"high\"\n  /** Name of the person it is assigned to. */\n  assignee?: string\n  /** Due date label, e.g. \"Oct 14\". */\n  due?: string\n  comments?: number\n}\n\ntype Kanban1Column = { id: string; title: string; tasks: Kanban1Task[] }\n\ntype Kanban1Props = Omit<React.ComponentProps<\"section\">, \"title\" | \"onChange\"> & {\n  /** Board heading. */\n  title?: string\n  /** One line under the heading. */\n  description?: string\n  /** Starting board. */\n  defaultColumns?: Kanban1Column[]\n  /** Called with the whole board after a task moves, is added or removed. */\n  onChange?: (columns: Kanban1Column[]) => void\n  /** Height of the board area. Columns scroll inside it. */\n  height?: string\n}\n\nconst priorityTone = { low: \"bg-chart-2\", medium: \"bg-chart-3\", high: \"bg-destructive\" } as const\n\nconst defaults: Kanban1Column[] = [\n  {\n    id: \"todo\", title: \"To do\",\n    tasks: [\n      { id: \"t1\", title: \"Write the Q4 launch announcement\", tags: [\"Marketing\"], priority: \"medium\", assignee: \"Elena Fischer\", due: \"Oct 14\", comments: 2 },\n      { id: \"t2\", title: \"Audit log export as CSV\", tags: [\"Engineering\"], priority: \"low\", assignee: \"Amara Singh\", due: \"Oct 21\" },\n      { id: \"t3\", title: \"Redraw empty states\", tags: [\"Design\"], priority: \"low\", assignee: \"Yuki Tanaka\" },\n    ],\n  },\n  {\n    id: \"doing\", title: \"In progress\",\n    tasks: [\n      { id: \"t4\", title: \"SAML SSO for Pro plans\", tags: [\"Engineering\", \"Security\"], priority: \"high\", assignee: \"Marcus Webb\", due: \"Oct 9\", comments: 5 },\n      { id: \"t5\", title: \"Pricing page experiment\", tags: [\"Growth\"], priority: \"medium\", assignee: \"Jonas Ortiz\", due: \"Oct 12\", comments: 1 },\n    ],\n  },\n  {\n    id: \"review\", title: \"In review\",\n    tasks: [{ id: \"t6\", title: \"Approval digest email\", tags: [\"Product\"], priority: \"medium\", assignee: \"Maya Kim\", due: \"Oct 6\", comments: 3 }],\n  },\n  {\n    id: \"done\", title: \"Done\",\n    tasks: [\n      { id: \"t7\", title: \"Faster invoice search\", tags: [\"Engineering\"], priority: \"low\", assignee: \"Amara Singh\" },\n      { id: \"t8\", title: \"Dark mode chart tooltips\", tags: [\"Design\"], assignee: \"Yuki Tanaka\" },\n    ],\n  },\n]\n\nconst tones = [\"bg-chart-1/25\", \"bg-chart-3/25\", \"bg-chart-5/25\", \"bg-chart-2/25\", \"bg-chart-4/25\"]\nconst initials = (name: string) => name.split(\" \").map((w) => w[0]).join(\"\").slice(0, 2)\nconst toneOf = (name: string) => tones[name.split(\"\").reduce((n, c) => n + c.charCodeAt(0), 0) % tones.length]\n\nfunction Kanban1({\n  title = \"Launch plan\",\n  description = \"Drag cards between columns, or pick one up with Space and move it with the arrow keys.\",\n  defaultColumns = defaults,\n  onChange,\n  height = \"34rem\",\n  className,\n  ...props\n}: Kanban1Props) {\n  const [cols, setCols] = React.useState(defaultColumns)\n  const [who, setWho] = React.useState(\"Everyone\")\n  const [grabbed, setGrabbed] = React.useState<{ id: string; from: { col: string; index: number } } | null>(null)\n  const [dragId, setDragId] = React.useState<string | null>(null)\n  const [over, setOver] = React.useState<{ col: string; index: number } | null>(null)\n  const [adding, setAdding] = React.useState<string | null>(null)\n  const [draft, setDraft] = React.useState(\"\")\n  const [announce, setAnnounce] = React.useState(\"\")\n  const focusId = React.useRef<string | null>(null)\n  const boardRef = React.useRef<HTMLDivElement>(null)\n\n  const people = [\"Everyone\", ...Array.from(new Set(cols.flatMap((c) => c.tasks.map((t) => t.assignee).filter((a): a is string => !!a))))]\n  const total = cols.reduce((n, c) => n + c.tasks.length, 0)\n\n  // After a card moves in the DOM, put keyboard focus back on it.\n  React.useEffect(() => {\n    if (!focusId.current) return\n    boardRef.current?.querySelector<HTMLElement>(`[data-task=\"${focusId.current}\"]`)?.focus()\n    focusId.current = null\n  })\n\n  const commit = (next: Kanban1Column[]) => {\n    setCols(next)\n    onChange?.(next)\n  }\n\n  function locate(id: string) {\n    for (const c of cols) {\n      const i = c.tasks.findIndex((t) => t.id === id)\n      if (i !== -1) return { col: c.id, index: i }\n    }\n    return null\n  }\n\n  function move(id: string, toCol: string, toIndex: number) {\n    const from = locate(id)\n    if (!from) return\n    const task = cols.find((c) => c.id === from.col)?.tasks[from.index]\n    if (!task) return\n    const without = cols.map((c) => (c.id === from.col ? { ...c, tasks: c.tasks.filter((t) => t.id !== id) } : c))\n    const next = without.map((c) => {\n      if (c.id !== toCol) return c\n      const index = Math.max(0, Math.min(toIndex, c.tasks.length))\n      const tasks = [...c.tasks]\n      tasks.splice(index, 0, task)\n      return { ...c, tasks }\n    })\n    focusId.current = id\n    commit(next)\n  }\n\n  function onCardKey(e: React.KeyboardEvent, task: Kanban1Task, colId: string, index: number) {\n    const colIndex = cols.findIndex((c) => c.id === colId)\n    if (!grabbed) {\n      if (e.key === \" \" || e.key === \"Enter\") {\n        e.preventDefault()\n        setGrabbed({ id: task.id, from: { col: colId, index } })\n        setAnnounce(`Picked up ${task.title}. Use the arrow keys to move it, Space to drop, Escape to cancel.`)\n      }\n      return\n    }\n    if (grabbed.id !== task.id) return\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\") {\n      e.preventDefault()\n      const target = cols[colIndex + (e.key === \"ArrowRight\" ? 1 : -1)]\n      if (!target) return\n      move(task.id, target.id, Math.min(index, target.tasks.length))\n      setAnnounce(`${task.title} moved to ${target.title}, position ${Math.min(index, target.tasks.length) + 1}.`)\n    } else if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault()\n      const to = index + (e.key === \"ArrowDown\" ? 1 : -1)\n      const column = cols[colIndex]\n      if (to < 0 || to >= column.tasks.length) return\n      move(task.id, colId, to)\n      setAnnounce(`${task.title} moved to position ${to + 1} of ${column.tasks.length}.`)\n    } else if (e.key === \" \" || e.key === \"Enter\") {\n      e.preventDefault()\n      setGrabbed(null)\n      setAnnounce(`Dropped ${task.title}.`)\n    } else if (e.key === \"Escape\") {\n      e.preventDefault()\n      move(task.id, grabbed.from.col, grabbed.from.index)\n      setGrabbed(null)\n      setAnnounce(`Cancelled. ${task.title} is back where it was.`)\n    }\n  }\n\n  function dropIndex(colEl: HTMLElement, y: number) {\n    const cards = Array.from(colEl.querySelectorAll<HTMLElement>(\"[data-card]\"))\n    let i = cards.length\n    for (let k = 0; k < cards.length; k++) {\n      const r = cards[k].getBoundingClientRect()\n      if (y < r.top + r.height / 2) {\n        i = k\n        break\n      }\n    }\n    return i\n  }\n\n  function addTask(colId: string) {\n    const text = draft.trim()\n    if (!text) return setAdding(null)\n    const id = `new-${total + 1}`\n    commit(cols.map((c) => (c.id === colId ? { ...c, tasks: [...c.tasks, { id, title: text }] } : c)))\n    setDraft(\"\")\n    setAdding(null)\n    setAnnounce(`Added ${text}.`)\n  }\n\n  function remove(task: Kanban1Task) {\n    commit(cols.map((c) => ({ ...c, tasks: c.tasks.filter((t) => t.id !== task.id) })))\n    setAnnounce(`Removed ${task.title}.`)\n  }\n\n  return (\n    <section data-slot=\"kanban-1\" className={cn(\"mx-auto w-full max-w-7xl px-4 py-8 sm:px-6\", className)} {...props}>\n      <p role=\"status\" className=\"sr-only\">{announce}</p>\n      <div className=\"flex flex-wrap items-end justify-between gap-4 px-1\">\n        <div className=\"max-w-xl\">\n          <h2 className=\"text-2xl font-semibold tracking-[-0.03em]\">{title}</h2>\n          <p className=\"text-muted-foreground mt-1 text-sm text-pretty\">{description}</p>\n        </div>\n        <div role=\"group\" aria-label=\"Filter by person\" className=\"flex max-w-full gap-1.5 overflow-x-auto pb-1 [scrollbar-width:none]\">\n          {people.map((p) => (\n            <button key={p} type=\"button\" aria-pressed={who === p} onClick={() => setWho(p)} className={cn(\"focus-visible:ring-ring/50 h-8 shrink-0 rounded-full border px-3.5 text-[13px] font-medium whitespace-nowrap outline-none transition-colors focus-visible:ring-[3px]\", who === p ? \"bg-foreground text-background border-transparent\" : \"text-muted-foreground hover:text-foreground hover:bg-accent\")}>{p === \"Everyone\" ? p : p.split(\" \")[0]}</button>\n          ))}\n        </div>\n      </div>\n\n      <div ref={boardRef} className=\"mt-5 flex snap-x gap-3 overflow-x-auto pb-2\" style={{ height }} tabIndex={-1}>\n        {cols.map((col) => {\n          const tasks = col.tasks.filter((t) => who === \"Everyone\" || t.assignee === who)\n          return (\n            <div\n              key={col.id}\n              role=\"group\"\n              aria-label={`${col.title}, ${col.tasks.length} tasks`}\n              data-column={col.id}\n              onDragOver={(e) => { if (!dragId) return; e.preventDefault(); setOver({ col: col.id, index: dropIndex(e.currentTarget, e.clientY) }) }}\n              onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setOver(null) }}\n              onDrop={(e) => { e.preventDefault(); if (dragId && over) { const real = who === \"Everyone\" ? over.index : cols.find((c) => c.id === col.id)!.tasks.length; move(dragId, col.id, real); setAnnounce(`Moved to ${col.title}.`) } setDragId(null); setOver(null) }}\n              className={cn(\"bg-muted/40 flex w-72 shrink-0 snap-start flex-col md:min-w-64 md:flex-1 rounded-2xl border p-2 transition-colors motion-reduce:transition-none\", over?.col === col.id && \"bg-accent/60 border-foreground/30\")}\n            >\n              <div className=\"flex items-center justify-between px-2 py-2\">\n                <h3 className=\"flex items-center gap-2 text-sm font-semibold\">{col.title}<span className=\"bg-background text-muted-foreground rounded-full border px-2 py-px text-xs font-medium tabular-nums\">{col.tasks.length}</span></h3>\n                <Button variant=\"ghost\" size=\"icon\" className=\"size-7\" aria-label={`Add a task to ${col.title}`} onClick={() => { setAdding(col.id); setDraft(\"\") }}><Plus /></Button>\n              </div>\n              <ul className=\"min-h-0 flex-1 space-y-2 overflow-y-auto p-1\">\n                {tasks.map((t, i) => {\n                  const index = col.tasks.indexOf(t)\n                  const picked = grabbed?.id === t.id\n                  return (\n                    <React.Fragment key={t.id}>\n                      {over?.col === col.id && over.index === index && dragId !== t.id && <li aria-hidden=\"true\" className=\"bg-foreground/60 -my-0.5 h-0.5 rounded-full\" />}\n                      <li data-card=\"\" className={cn(\"group/card relative\", dragId === t.id && \"opacity-40\")}>\n                        <div\n                          draggable\n                          data-task={t.id}\n                          tabIndex={0}\n                          role=\"button\"\n                          aria-roledescription=\"draggable task\"\n                          aria-pressed={picked}\n                          aria-label={`${t.title}${t.assignee ? `, assigned to ${t.assignee}` : \"\"}${t.due ? `, due ${t.due}` : \"\"}. Press Space to pick up.`}\n                          onKeyDown={(e) => onCardKey(e, t, col.id, index)}\n                          onBlur={() => picked && setGrabbed(null)}\n                          onDragStart={(e) => { setDragId(t.id); e.dataTransfer.effectAllowed = \"move\"; e.dataTransfer.setData(\"text/plain\", t.id) }}\n                          onDragEnd={() => { setDragId(null); setOver(null) }}\n                          className={cn(\"bg-card focus-visible:ring-ring/50 cursor-grab rounded-xl border p-3.5 shadow-xs outline-none transition-shadow focus-visible:ring-[3px] active:cursor-grabbing motion-reduce:transition-none hover:shadow-md\", picked && \"ring-foreground ring-2 shadow-lg\")}\n                        >\n                          {t.tags && t.tags.length > 0 && <div className=\"mb-2 flex flex-wrap gap-1\">{t.tags.map((tag) => <Badge key={tag} variant=\"secondary\" className=\"px-1.5 py-0 text-[11px]\">{tag}</Badge>)}</div>}\n                          <p className=\"pe-6 text-sm leading-snug font-medium text-pretty\">{t.title}</p>\n                          <div className=\"mt-3 flex items-center gap-3 text-xs\">\n                            {t.priority && <span className=\"text-muted-foreground flex items-center gap-1.5\"><span aria-hidden=\"true\" className={cn(\"size-2 rounded-full\", priorityTone[t.priority])} />{t.priority[0].toUpperCase() + t.priority.slice(1)}</span>}\n                            {t.due && <span className=\"text-muted-foreground flex items-center gap-1\"><CalendarDays className=\"size-3.5\" aria-hidden=\"true\" />{t.due}</span>}\n                            {t.comments ? <span className=\"text-muted-foreground flex items-center gap-1\"><MessageSquare className=\"size-3.5\" aria-hidden=\"true\" />{t.comments}<span className=\"sr-only\"> comments</span></span> : null}\n                            {t.assignee && <Avatar size=\"sm\" className=\"ms-auto size-6\"><AvatarFallback className={cn(\"text-foreground text-[10px] font-semibold\", toneOf(t.assignee))}>{initials(t.assignee)}</AvatarFallback></Avatar>}\n                          </div>\n                        </div>\n                        <DropdownMenu>\n                          <DropdownMenuTrigger aria-label={`Actions for ${t.title}`} className=\"hover:bg-accent focus-visible:ring-ring/50 absolute top-2 end-2 flex size-7 items-center justify-center rounded-md opacity-0 outline-none group-focus-within/card:opacity-100 group-hover/card:opacity-100 focus-visible:ring-[3px] data-[state=open]:opacity-100 motion-reduce:transition-none\"><MoreHorizontal className=\"size-4\" aria-hidden=\"true\" /></DropdownMenuTrigger>\n                          <DropdownMenuContent align=\"end\">\n                            <DropdownMenuLabel>Move to</DropdownMenuLabel>\n                            {cols.filter((c) => c.id !== col.id).map((c) => <DropdownMenuItem key={c.id} onSelect={() => { move(t.id, c.id, c.tasks.length); setAnnounce(`${t.title} moved to ${c.title}.`) }}>{c.title}</DropdownMenuItem>)}\n                            <DropdownMenuSeparator />\n                            <DropdownMenuItem destructive onSelect={() => remove(t)}><X /> Remove</DropdownMenuItem>\n                          </DropdownMenuContent>\n                        </DropdownMenu>\n                      </li>\n                      {i === tasks.length - 1 && over?.col === col.id && over.index >= col.tasks.length && <li aria-hidden=\"true\" className=\"bg-foreground/60 -my-0.5 h-0.5 rounded-full\" />}\n                    </React.Fragment>\n                  )\n                })}\n                {tasks.length === 0 && <li className=\"text-muted-foreground rounded-xl border border-dashed px-3 py-8 text-center text-xs\">{over?.col === col.id ? \"Drop here\" : \"No tasks\"}</li>}\n              </ul>\n              {adding === col.id ? (\n                <form className=\"p-1\" onSubmit={(e) => { e.preventDefault(); addTask(col.id) }}>\n                  <Input autoFocus aria-label={`New task in ${col.title}`} placeholder=\"Task title\" value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => e.key === \"Escape\" && setAdding(null)} onBlur={() => addTask(col.id)} />\n                </form>\n              ) : null}\n            </div>\n          )\n        })}\n      </div>\n    </section>\n  )\n}\n\nexport { Kanban1, type Kanban1Props, type Kanban1Column, type Kanban1Task }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/kanban-1/kanban-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "kanban",
      "board",
      "tasks",
      "drag and drop",
      "project",
      "keyboard"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "A task board. Pass defaultColumns=[{ id, title, tasks: [{ id, title, tags?, priority?, assignee?, due?, comments? }] }] and onChange(columns).",
      "whenToUse": [
        "Project and sprint boards",
        "Pipelines, hiring and support queues"
      ],
      "whenNotToUse": [
        "A simple list of tasks (use a table)"
      ],
      "composesWith": [
        "app-shell-1",
        "calendar-1",
        "mail-1"
      ],
      "a11y": [
        {
          "keys": "Space or Enter on a card",
          "action": "Picks it up; Arrow keys move it between columns and positions; Space or Enter drops it; Escape puts it back"
        },
        {
          "keys": "Move to menu",
          "action": "Every card has a menu that moves it to any column, for people who don't drag"
        },
        {
          "keys": "Announcements",
          "action": "Each pick-up, move, drop and removal is announced politely"
        }
      ],
      "customization": [
        "defaultColumns and onChange(columns)",
        "height of the board area",
        "priority: low, medium or high"
      ]
    },
    "examples": [
      "kanban-1-demo",
      "kanban-1-simple"
    ],
    "url": "https://ui.ballmac.com/components/kanban-1"
  },
  "categories": [
    "blocks",
    "kanban"
  ],
  "type": "registry:block"
}