Skip to content
UI

Search docs

Search components and documentation

New
← Back to application blocks

kanban-01

A three-column kanban board (to do / in progress / done) with cards you drag between columns, using native HTML5 drag-and-drop — no dependencies. Columns show a live count and highlight while a card is dragged over them. Use as the base for a task board or pipeline view.

Code

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"

type Task = { id: string; title: string; tag?: string }
type ColumnId = "todo" | "doing" | "done"

const COLUMNS: { id: ColumnId; title: string }[] = [
  { id: "todo", title: "To do" },
  { id: "doing", title: "In progress" },
  { id: "done", title: "Done" },
]

const INITIAL: Record<ColumnId, Task[]> = {
  todo: [
    { id: "t1", title: "Draft the launch post", tag: "Marketing" },
    { id: "t2", title: "Audit color contrast", tag: "A11y" },
  ],
  doing: [
    { id: "t3", title: "Wire up the MCP endpoint", tag: "Backend" },
    { id: "t4", title: "Motion presets", tag: "Design" },
  ],
  done: [{ id: "t5", title: "Ship the registry", tag: "Platform" }],
}

export function Kanban({ className, ...props }: React.ComponentProps<"div">) {
  const [columns, setColumns] = React.useState(INITIAL)
  const [dragging, setDragging] = React.useState<{
    task: Task
    from: ColumnId
  } | null>(null)
  const [over, setOver] = React.useState<ColumnId | null>(null)

  const drop = (to: ColumnId) => {
    setOver(null)
    if (!dragging) return
    const { task, from } = dragging
    setDragging(null)
    if (from === to) return
    setColumns((prev) => ({
      ...prev,
      [from]: prev[from].filter((t) => t.id !== task.id),
      [to]: [...prev[to], task],
    }))
  }

  return (
    <div
      className={cn(
        "mx-auto grid w-full max-w-5xl gap-4 px-6 py-10 sm:grid-cols-3",
        className
      )}
      {...props}
    >
      {COLUMNS.map((column) => (
        <div
          key={column.id}
          data-over={over === column.id ? "" : undefined}
          onDragOver={(e) => {
            e.preventDefault()
            setOver(column.id)
          }}
          onDragLeave={(e) => {
            if (!e.currentTarget.contains(e.relatedTarget as Node))
              setOver((c) => (c === column.id ? null : c))
          }}
          onDrop={() => drop(column.id)}
          className="bg-muted/30 data-over:border-ring data-over:bg-accent/40 flex flex-col rounded-lg border p-3 transition-colors"
        >
          <div className="mb-3 flex items-center justify-between">
            <h3 className="text-sm font-medium">{column.title}</h3>
            <Badge variant="secondary" className="tabular-nums">
              {columns[column.id].length}
            </Badge>
          </div>
          <div className="flex min-h-16 flex-col gap-2">
            {columns[column.id].map((task) => (
              <div
                key={task.id}
                draggable
                onDragStart={() => setDragging({ task, from: column.id })}
                onDragEnd={() => {
                  setDragging(null)
                  setOver(null)
                }}
                data-slot="kanban-card"
                className="bg-card cursor-grab rounded-md border p-3 shadow-xs transition-shadow hover:shadow-md active:cursor-grabbing"
              >
                <p className="text-sm font-medium">{task.title}</p>
                {task.tag && (
                  <Badge variant="outline" className="mt-2 text-xs">
                    {task.tag}
                  </Badge>
                )}
              </div>
            ))}
          </div>
        </div>
      ))}
    </div>
  )
}