{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kanban-3",
  "title": "Four Column Project Board",
  "description": "Project kanban board with To Do, In Progress, Review, and Done columns, per-column counts, and drag and drop reordering across columns.",
  "dependencies": [
    "@dnd-kit/core",
    "@dnd-kit/sortable",
    "@dnd-kit/utilities",
    "@base-ui/react"
  ],
  "registryDependencies": [
    "avatar",
    "badge",
    "card",
    "scroll-area"
  ],
  "files": [
    {
      "path": "registry/blocks/kanban/3/kanban-block.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  DndContext,\n  DragOverlay,\n  KeyboardSensor,\n  PointerSensor,\n  closestCorners,\n  useDroppable,\n  useSensor,\n  useSensors,\n  type DragEndEvent,\n  type DragOverEvent,\n  type DragStartEvent,\n} from \"@dnd-kit/core\"\nimport {\n  SortableContext,\n  arrayMove,\n  sortableKeyboardCoordinates,\n  useSortable,\n  verticalListSortingStrategy,\n} from \"@dnd-kit/sortable\"\nimport { CSS } from \"@dnd-kit/utilities\"\nimport { cn } from \"@/lib/utils\"\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Card, CardContent } from \"@/components/ui/card\"\nimport { ScrollArea } from \"@/components/ui/scroll-area\"\nimport { IconPlaceholder } from \"@/components/icons/icon-placeholder\"\n\ntype Label = \"Design\" | \"Engineering\" | \"Marketing\" | \"Ops\"\n\nconst labelVariant: Record<Label, \"default\" | \"secondary\" | \"outline\"> = {\n  Design: \"secondary\",\n  Engineering: \"default\",\n  Marketing: \"outline\",\n  Ops: \"outline\",\n}\n\ntype Task = {\n  id: string\n  title: string\n  label: Label\n  assignee: string\n  initials: string\n  avatarSrc: string\n}\n\ntype Column = { id: string; title: string; tasks: Task[] }\n\nconst initialColumns: Column[] = [\n  {\n    id: \"todo\",\n    title: \"To Do\",\n    tasks: [\n      {\n        id: \"k1\",\n        title: \"Draft Q3 launch announcement\",\n        label: \"Marketing\",\n        assignee: \"Ravi Patel\",\n        initials: \"RP\",\n        avatarSrc: \"https://i.pravatar.cc/32?img=15\",\n      },\n      {\n        id: \"k2\",\n        title: \"Design empty states for reports\",\n        label: \"Design\",\n        assignee: \"Mia Cho\",\n        initials: \"MC\",\n        avatarSrc: \"https://i.pravatar.cc/32?img=47\",\n      },\n      {\n        id: \"k3\",\n        title: \"Set up staging environment\",\n        label: \"Ops\",\n        assignee: \"Leo Fenn\",\n        initials: \"LF\",\n        avatarSrc: \"https://i.pravatar.cc/32?img=51\",\n      },\n    ],\n  },\n  {\n    id: \"in-progress\",\n    title: \"In Progress\",\n    tasks: [\n      {\n        id: \"k4\",\n        title: \"Build billing usage endpoint\",\n        label: \"Engineering\",\n        assignee: \"Dana Wu\",\n        initials: \"DW\",\n        avatarSrc: \"https://i.pravatar.cc/32?img=32\",\n      },\n      {\n        id: \"k5\",\n        title: \"Rework onboarding checklist\",\n        label: \"Design\",\n        assignee: \"Mia Cho\",\n        initials: \"MC\",\n        avatarSrc: \"https://i.pravatar.cc/32?img=47\",\n      },\n    ],\n  },\n  {\n    id: \"review\",\n    title: \"Review\",\n    tasks: [\n      {\n        id: \"k6\",\n        title: \"Audit tracking events\",\n        label: \"Marketing\",\n        assignee: \"Ravi Patel\",\n        initials: \"RP\",\n        avatarSrc: \"https://i.pravatar.cc/32?img=15\",\n      },\n    ],\n  },\n  {\n    id: \"done\",\n    title: \"Done\",\n    tasks: [\n      {\n        id: \"k7\",\n        title: \"Migrate assets to CDN\",\n        label: \"Ops\",\n        assignee: \"Leo Fenn\",\n        initials: \"LF\",\n        avatarSrc: \"https://i.pravatar.cc/32?img=51\",\n      },\n      {\n        id: \"k8\",\n        title: \"Ship dark-mode tokens\",\n        label: \"Engineering\",\n        assignee: \"Dana Wu\",\n        initials: \"DW\",\n        avatarSrc: \"https://i.pravatar.cc/32?img=32\",\n      },\n    ],\n  },\n]\n\nfunction TaskCardBody({ task, dragging }: { task: Task; dragging?: boolean }) {\n  return (\n    <Card\n      size=\"sm\"\n      className={cn(\n        \"gap-2 transition-shadow duration-150 hover:shadow-md\",\n        dragging && \"shadow-lg ring-1 ring-foreground/15\"\n      )}\n    >\n      <CardContent className=\"flex flex-col gap-2.5\">\n        <p className=\"text-sm leading-snug font-medium\">{task.title}</p>\n        <div className=\"flex items-center justify-between gap-2\">\n          <Badge variant={labelVariant[task.label]}>{task.label}</Badge>\n          <Avatar size=\"sm\">\n            <AvatarImage\n              src={task.avatarSrc}\n              alt={task.assignee}\n              className=\"grayscale\"\n            />\n            <AvatarFallback>{task.initials}</AvatarFallback>\n          </Avatar>\n        </div>\n      </CardContent>\n    </Card>\n  )\n}\n\nfunction SortableTaskCard({ task }: { task: Task }) {\n  const {\n    attributes,\n    listeners,\n    setNodeRef,\n    transform,\n    transition,\n    isDragging,\n  } = useSortable({ id: task.id })\n  return (\n    <div\n      ref={setNodeRef}\n      style={{\n        transform: CSS.Transform.toString(transform),\n        transition,\n        opacity: isDragging ? 0 : 1,\n      }}\n      {...attributes}\n      {...listeners}\n      className=\"cursor-grab touch-none outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing\"\n    >\n      <TaskCardBody task={task} />\n    </div>\n  )\n}\n\nfunction BoardColumn({\n  column,\n  reflowKey,\n}: {\n  column: Column\n  reflowKey: number\n}) {\n  const { setNodeRef, isOver } = useDroppable({ id: column.id })\n  return (\n    <div className=\"flex w-[80%] shrink-0 flex-col gap-3 sm:w-auto sm:shrink\">\n      <div className=\"flex items-center gap-2\">\n        <span className=\"text-xs font-semibold tracking-wider text-muted-foreground uppercase\">\n          {column.title}\n        </span>\n        <span className=\"flex size-5 items-center justify-center rounded-md border border-border text-[10px] font-semibold text-muted-foreground tabular-nums\">\n          {column.tasks.length}\n        </span>\n      </div>\n      <SortableContext\n        items={column.tasks.map((t) => t.id)}\n        strategy={verticalListSortingStrategy}\n      >\n        <ScrollArea\n          key={reflowKey}\n          className={cn(\n            \"h-80 rounded-lg border border-border transition-colors [&_[data-slot=scroll-area-viewport]]:scroll-fade-y\",\n            isOver ? \"border-foreground/30 bg-muted/40\" : \"bg-muted/20\"\n          )}\n        >\n          <div ref={setNodeRef} className=\"flex min-h-full flex-col gap-2 p-2\">\n            {column.tasks.map((task) => (\n              <SortableTaskCard key={task.id} task={task} />\n            ))}\n            {column.tasks.length === 0 && (\n              <div className=\"flex flex-1 items-center justify-center rounded-lg border border-dashed border-border py-8 text-xs text-muted-foreground\">\n                Drop here\n              </div>\n            )}\n            <button\n              type=\"button\"\n              className=\"mt-auto flex items-center gap-1.5 px-1 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground\"\n            >\n              <IconPlaceholder\n                lucide=\"Plus\"\n                tabler=\"IconPlus\"\n                hugeicons=\"Add01Icon\"\n                phosphor=\"Plus\"\n                remixicon=\"RiAddLine\"\n                className=\"size-3.5\"\n                aria-hidden=\"true\"\n              />\n              Add card\n            </button>\n          </div>\n        </ScrollArea>\n      </SortableContext>\n    </div>\n  )\n}\n\nexport default function KanbanBlock() {\n  const [columns, setColumns] = React.useState<Column[]>(initialColumns)\n  const [activeTask, setActiveTask] = React.useState<Task | null>(null)\n  const [reflow, setReflow] = React.useState(0)\n\n  const sensors = useSensors(\n    useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),\n    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })\n  )\n\n  const findColumnId = React.useCallback(\n    (id: string) =>\n      columns.some((c) => c.id === id)\n        ? id\n        : columns.find((c) => c.tasks.some((t) => t.id === id))?.id,\n    [columns]\n  )\n\n  function handleDragStart(event: DragStartEvent) {\n    const id = String(event.active.id)\n    setActiveTask(\n      columns.flatMap((c) => c.tasks).find((t) => t.id === id) ?? null\n    )\n  }\n\n  function handleDragOver(event: DragOverEvent) {\n    const { active, over } = event\n    if (!over) return\n    const activeId = String(active.id)\n    const overId = String(over.id)\n    const activeCol = findColumnId(activeId)\n    const overCol = findColumnId(overId)\n    if (!activeCol || !overCol || activeCol === overCol) return\n    setColumns((prev) => {\n      const from = prev.find((c) => c.id === activeCol)!\n      const moving = from.tasks.find((t) => t.id === activeId)\n      if (!moving) return prev\n      const to = prev.find((c) => c.id === overCol)!\n      const overIndex = to.tasks.findIndex((t) => t.id === overId)\n      const insertAt = overIndex >= 0 ? overIndex : to.tasks.length\n      return prev.map((c) => {\n        if (c.id === activeCol)\n          return { ...c, tasks: c.tasks.filter((t) => t.id !== activeId) }\n        if (c.id === overCol) {\n          const next = [...c.tasks]\n          next.splice(insertAt, 0, moving)\n          return { ...c, tasks: next }\n        }\n        return c\n      })\n    })\n  }\n\n  function handleDragEnd(event: DragEndEvent) {\n    const { active, over } = event\n    setActiveTask(null)\n    setReflow((n) => n + 1)\n    if (!over) return\n    const activeId = String(active.id)\n    const overId = String(over.id)\n    const col = findColumnId(activeId)\n    if (!col || col !== findColumnId(overId)) return\n    setColumns((prev) =>\n      prev.map((c) => {\n        if (c.id !== col) return c\n        const oldIndex = c.tasks.findIndex((t) => t.id === activeId)\n        const newIndex = c.tasks.findIndex((t) => t.id === overId)\n        if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return c\n        return { ...c, tasks: arrayMove(c.tasks, oldIndex, newIndex) }\n      })\n    )\n  }\n\n  return (\n    <section className=\"flex min-h-svh w-full items-start justify-center bg-background px-6 py-12 text-foreground\">\n      <div className=\"mx-auto w-full max-w-5xl\">\n        <div className=\"mb-8 border-b border-border pb-5\">\n          <p className=\"mb-1 text-xs font-medium tracking-widest text-muted-foreground uppercase\">\n            Acme Workspace\n          </p>\n          <h1 className=\"font-heading text-2xl font-bold tracking-tight\">\n            Project Board\n          </h1>\n        </div>\n\n        <DndContext\n          id=\"kanban-3-board\"\n          sensors={sensors}\n          collisionDetection={closestCorners}\n          onDragStart={handleDragStart}\n          onDragOver={handleDragOver}\n          onDragEnd={handleDragEnd}\n          onDragCancel={() => {\n            setActiveTask(null)\n            setReflow((n) => n + 1)\n          }}\n        >\n          <div className=\"-mx-1 flex gap-4 overflow-x-auto px-1 pb-3 sm:mx-0 sm:grid sm:grid-cols-4 sm:overflow-visible sm:px-0 sm:pb-0\">\n            {columns.map((col) => (\n              <BoardColumn key={col.id} column={col} reflowKey={reflow} />\n            ))}\n          </div>\n          <DragOverlay>\n            {activeTask ? (\n              <div className=\"cursor-grabbing\">\n                <TaskCardBody task={activeTask} dragging />\n              </div>\n            ) : null}\n          </DragOverlay>\n        </DndContext>\n      </div>\n    </section>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/kanban-3.tsx"
    }
  ],
  "meta": {
    "height": "561px",
    "tier": "free"
  },
  "categories": [
    "kanban"
  ],
  "type": "registry:block"
}