# Kanban Board

> Kanban board with native drag and drop between columns, drop indicators and quick add.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/kanban-board
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/kanban-board.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/kanban-board-demo.tsx"
import { KanbanBoard } from "@/components/kanban-board"

const columns = [
  { id: "backlog", title: "Backlog", color: "#a1a1aa" },
  { id: "progress", title: "In progress", color: "#f97316" },
  { id: "review", title: "In review", color: "#a855f7" },
  { id: "done", title: "Done", color: "#22c55e" },
]

const cards = [
  { id: "c1", columnId: "backlog", title: "Audit color tokens for contrast", tags: ["Design"], assignee: "AL" },
  { id: "c2", columnId: "backlog", title: "Add RTL support to the tabs", tags: ["i18n"] },
  { id: "c3", columnId: "backlog", title: "Write migration guide for v2", tags: ["Docs"], assignee: "GH" },
  { id: "c4", columnId: "progress", title: "Command palette fuzzy ranking", tags: ["Feature"], assignee: "AT" },
  { id: "c5", columnId: "progress", title: "Drawer drag-to-close on touch", tags: ["Mobile"], assignee: "MH" },
  { id: "c6", columnId: "review", title: "Data table column sorting", tags: ["Feature"], assignee: "LT" },
  { id: "c7", columnId: "done", title: "Ship the date picker", tags: ["Release"], assignee: "AL" },
  { id: "c8", columnId: "done", title: "Fix focus ring in Safari", tags: ["Bug"] },
]

export default function KanbanBoardDemo() {
  return <KanbanBoard columns={columns} defaultCards={cards} columnWidth={264} className="max-w-full" />
}
```

## Source

```tsx title="components/kanban-board.tsx"
"use client"

import * as React from "react"
import { GripVertical, Plus } from "lucide-react"

import { cn } from "@/lib/utils"

export interface KanbanColumn {
  id: string
  title: string
  /** Dot color in the column header */
  color?: string
}

export interface KanbanCard {
  id: string
  columnId: string
  title: string
  tags?: string[]
  /** Initials or short label shown in the avatar bubble */
  assignee?: string
}

export interface KanbanBoardProps extends Omit<React.ComponentProps<"div">, "onChange"> {
  columns: KanbanColumn[]
  defaultCards?: KanbanCard[]
  onCardsChange?: (cards: KanbanCard[]) => void
  /** Column width in px */
  columnWidth?: number
  showCount?: boolean
  /** Show an "Add card" button under each column */
  allowAdd?: boolean
}

type DropTarget = { columnId: string; index: number } | null

export function KanbanBoard({
  columns,
  defaultCards = [],
  onCardsChange,
  columnWidth = 280,
  showCount = true,
  allowAdd = true,
  className,
  ...props
}: KanbanBoardProps) {
  const [cards, setCards] = React.useState(defaultCards)
  const [dragging, setDragging] = React.useState<string | null>(null)
  const [drop, setDrop] = React.useState<DropTarget>(null)
  const [adding, setAdding] = React.useState<string | null>(null)
  const created = React.useRef(0)

  const update = (next: KanbanCard[]) => {
    setCards(next)
    onCardsChange?.(next)
  }

  const move = (id: string, columnId: string, index: number) => {
    const card = cards.find((item) => item.id === id)
    if (!card) return
    const rest = cards.filter((item) => item.id !== id)
    const target = rest.filter((item) => item.columnId === columnId)
    target.splice(index, 0, { ...card, columnId })
    update([...rest.filter((item) => item.columnId !== columnId), ...target])
  }

  const onDragOver = (event: React.DragEvent<HTMLElement>, columnId: string) => {
    if (!dragging) return
    event.preventDefault()
    event.dataTransfer.dropEffect = "move"
    // Insert before the first card whose midpoint sits below the pointer
    const others = [...event.currentTarget.querySelectorAll<HTMLElement>("[data-card]:not([data-dragging])")]
    let index = others.findIndex((el) => {
      const rect = el.getBoundingClientRect()
      return event.clientY < rect.top + rect.height / 2
    })
    if (index === -1) index = others.length
    if (drop?.columnId !== columnId || drop.index !== index) setDrop({ columnId, index })
  }

  const addCard = (columnId: string, title: string) => {
    created.current += 1
    update([...cards, { id: `new-${created.current}`, columnId, title }])
  }

  return (
    <div className={cn("flex gap-4 overflow-x-auto pb-2", className)} {...props}>
      {columns.map((column) => {
        const columnCards = cards.filter((card) => card.columnId === column.id)
        const visibleCards = columnCards.filter((card) => card.id !== dragging)
        const indicator = drop?.columnId === column.id ? drop.index : -1
        return (
          <section
            key={column.id}
            aria-label={column.title}
            style={{ width: columnWidth }}
            onDragOver={(event) => onDragOver(event, column.id)}
            onDragLeave={(event) => {
              if (!event.currentTarget.contains(event.relatedTarget as Node)) setDrop(null)
            }}
            onDrop={(event) => {
              event.preventDefault()
              if (dragging && drop) move(dragging, drop.columnId, drop.index)
              setDragging(null)
              setDrop(null)
            }}
            className={cn(
              "flex shrink-0 flex-col rounded-2xl border bg-muted/40 p-2 transition-colors",
              indicator !== -1 && "border-primary/40 bg-primary/5"
            )}
          >
            <header className="flex items-center gap-2 px-2 pt-1 pb-3">
              <span className="size-2 rounded-full" style={{ background: column.color ?? "currentColor" }} />
              <h3 className="text-sm font-medium">{column.title}</h3>
              {showCount && (
                <span className="ml-auto rounded-full bg-background px-2 text-xs text-muted-foreground tabular-nums">{columnCards.length}</span>
              )}
            </header>
            <ul className="flex min-h-16 flex-col gap-2">
              {columnCards.map((card) => {
                const position = visibleCards.indexOf(card)
                return (
                  <React.Fragment key={card.id}>
                    {indicator === position && position !== -1 && <DropLine />}
                    <li
                      data-card
                      data-dragging={card.id === dragging || undefined}
                      draggable
                      onDragStart={(event) => {
                        event.dataTransfer.effectAllowed = "move"
                        event.dataTransfer.setData("text/plain", card.id)
                        // Let the browser snapshot the card before it fades
                        requestAnimationFrame(() => setDragging(card.id))
                      }}
                      onDragEnd={() => {
                        setDragging(null)
                        setDrop(null)
                      }}
                      className="group flex cursor-grab items-start gap-2 rounded-xl border bg-card p-3 shadow-xs transition-[opacity,box-shadow] hover:shadow-md active:cursor-grabbing data-dragging:opacity-40"
                    >
                      <GripVertical className="mt-0.5 size-4 shrink-0 text-muted-foreground/50 transition-colors group-hover:text-muted-foreground" />
                      <div className="flex min-w-0 flex-1 flex-col gap-2">
                        <p className="text-sm leading-snug">{card.title}</p>
                        {Boolean(card.tags?.length || card.assignee) && (
                          <div className="flex items-center gap-1.5">
                            {card.tags?.map((tag) => (
                              <span key={tag} className="rounded-md bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
                                {tag}
                              </span>
                            ))}
                            {card.assignee && (
                              <span className="ml-auto flex size-6 items-center justify-center rounded-full bg-primary/15 text-[10px] font-semibold text-primary">
                                {card.assignee}
                              </span>
                            )}
                          </div>
                        )}
                      </div>
                    </li>
                  </React.Fragment>
                )
              })}
              {indicator === visibleCards.length && <DropLine />}
            </ul>
            {allowAdd &&
              (adding === column.id ? (
                <form
                  className="mt-2"
                  onSubmit={(event) => {
                    event.preventDefault()
                    const input = event.currentTarget.elements.namedItem("title") as HTMLInputElement
                    if (input.value.trim()) addCard(column.id, input.value.trim())
                    setAdding(null)
                  }}
                >
                  <input
                    name="title"
                    autoFocus
                    placeholder="Card title…"
                    aria-label={`New card in ${column.title}`}
                    onBlur={() => setAdding(null)}
                    onKeyDown={(event) => event.key === "Escape" && setAdding(null)}
                    className="h-9 w-full rounded-lg border bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
                  />
                </form>
              ) : (
                <button
                  type="button"
                  onClick={() => setAdding(column.id)}
                  className="mt-2 flex h-9 items-center gap-1.5 rounded-lg px-2 text-sm text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
                >
                  <Plus className="size-4" /> Add card
                </button>
              ))}
          </section>
        )
      })}
    </div>
  )
}

function DropLine() {
  return <li aria-hidden className="h-0.5 rounded-full bg-primary" />
}
```
