Components

Kanban Board

A drag-and-drop kanban board with horizontally scrolling columns, rich task cards, and an accessible move menu fallback.

layout

Installation

npx cano-ui add kanban-board

Installs avatar, badge, button, dropdown-menu, lucide-react automatically if missing.

Source

"use client"

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

import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

interface KanbanAssignee {
  name: string
  image?: string
}

interface KanbanCard {
  id: string
  title: string
  description?: string
  labels?: string[]
  assignee?: KanbanAssignee
  dueDate?: string
}

interface KanbanColumn {
  id: string
  title: string
  cards: KanbanCard[]
}

interface KanbanBoardProps extends React.ComponentProps<"div"> {
  columns: KanbanColumn[]
  /**
   * Called when a card is moved via drag and drop or the per-card move menu.
   * `index` is the insertion position in the target column, computed against
   * the target column's card list before the card is removed from its source.
   *
   * Drop behavior: dropping on a card inserts before it (or after it when the
   * pointer is in the lower half of the card); dropping on a column's empty
   * area appends the card to the end.
   */
  onCardMove: (
    cardId: string,
    fromColumnId: string,
    toColumnId: string,
    index: number
  ) => void
  /** When provided, renders an add button in each column header. */
  onAddCard?: (columnId: string) => void
}

interface DragState {
  cardId: string
  fromColumnId: string
}

function getInitials(name: string) {
  return name
    .split(" ")
    .map((part) => part[0])
    .filter(Boolean)
    .slice(0, 2)
    .join("")
    .toUpperCase()
}

function KanbanBoard({
  columns,
  onCardMove,
  onAddCard,
  className,
  ...props
}: KanbanBoardProps) {
  const [dragState, setDragState] = React.useState<DragState | null>(null)
  const [dragOverColumnId, setDragOverColumnId] = React.useState<string | null>(
    null
  )

  const endDrag = React.useCallback(() => {
    setDragState(null)
    setDragOverColumnId(null)
  }, [])

  const handleDrop = React.useCallback(
    (toColumnId: string, index: number) => {
      if (dragState) {
        onCardMove(dragState.cardId, dragState.fromColumnId, toColumnId, index)
      }
      endDrag()
    },
    [dragState, onCardMove, endDrag]
  )

  return (
    <div
      className={cn("flex gap-4 overflow-x-auto pb-4", className)}
      {...props}
    >
      {columns.map((column) => (
        <div
          key={column.id}
          className={cn(
            "flex w-72 shrink-0 flex-col gap-3 rounded-lg p-2 transition-colors",
            dragOverColumnId === column.id && "bg-accent/50 ring-1 ring-border"
          )}
          onDragOver={(event) => {
            event.preventDefault()
            event.dataTransfer.dropEffect = "move"
            setDragOverColumnId(column.id)
          }}
          onDragLeave={(event) => {
            if (
              !event.currentTarget.contains(event.relatedTarget as Node | null)
            ) {
              setDragOverColumnId((current) =>
                current === column.id ? null : current
              )
            }
          }}
          onDrop={(event) => {
            event.preventDefault()
            handleDrop(column.id, column.cards.length)
          }}
        >
          <div className="flex items-center gap-2 px-1">
            <h3 className="text-sm font-medium">{column.title}</h3>
            <Badge variant="secondary">{column.cards.length}</Badge>
            {onAddCard ? (
              <Button
                variant="ghost"
                size="icon"
                className="ml-auto size-7"
                aria-label={`Add card to ${column.title}`}
                onClick={() => onAddCard(column.id)}
              >
                <Plus className="size-4" />
              </Button>
            ) : null}
          </div>
          <div className="flex flex-col gap-2">
            {column.cards.map((card, cardIndex) => (
              <div
                key={card.id}
                draggable
                onDragStart={(event) => {
                  event.dataTransfer.setData("text/plain", card.id)
                  event.dataTransfer.effectAllowed = "move"
                  setDragState({ cardId: card.id, fromColumnId: column.id })
                }}
                onDragEnd={endDrag}
                onDrop={(event) => {
                  event.preventDefault()
                  event.stopPropagation()
                  const rect = event.currentTarget.getBoundingClientRect()
                  const insertAfter =
                    event.clientY > rect.top + rect.height / 2
                  handleDrop(column.id, cardIndex + (insertAfter ? 1 : 0))
                }}
                className={cn(
                  "cursor-grab rounded-lg border bg-card p-3 shadow-xs active:cursor-grabbing",
                  dragState?.cardId === card.id && "opacity-50"
                )}
              >
                <div className="flex items-start gap-2">
                  <p className="flex-1 text-sm font-medium">{card.title}</p>
                  <DropdownMenu>
                    <DropdownMenuTrigger asChild>
                      <Button
                        variant="ghost"
                        size="icon"
                        className="-mt-1 -mr-1 size-6"
                        aria-label="Move card"
                      >
                        <MoreHorizontal className="size-4" />
                      </Button>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent align="end">
                      <DropdownMenuLabel>Move to</DropdownMenuLabel>
                      <DropdownMenuSeparator />
                      {columns
                        .filter((target) => target.id !== column.id)
                        .map((target) => (
                          <DropdownMenuItem
                            key={target.id}
                            onSelect={() =>
                              onCardMove(
                                card.id,
                                column.id,
                                target.id,
                                target.cards.length
                              )
                            }
                          >
                            Move to {target.title}
                          </DropdownMenuItem>
                        ))}
                    </DropdownMenuContent>
                  </DropdownMenu>
                </div>
                {card.description ? (
                  <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
                    {card.description}
                  </p>
                ) : null}
                {card.labels?.length || card.assignee || card.dueDate ? (
                  <div className="mt-3 flex items-center gap-2">
                    {card.labels?.map((label) => (
                      <Badge
                        key={label}
                        variant="outline"
                        className="px-1.5 py-0 text-[10px]"
                      >
                        {label}
                      </Badge>
                    ))}
                    <div className="ml-auto flex items-center gap-2">
                      {card.dueDate ? (
                        <span className="text-xs text-muted-foreground">
                          {card.dueDate}
                        </span>
                      ) : null}
                      {card.assignee ? (
                        <Avatar className="size-5">
                          <AvatarImage
                            src={card.assignee.image}
                            alt={card.assignee.name}
                          />
                          <AvatarFallback className="text-[9px]">
                            {getInitials(card.assignee.name)}
                          </AvatarFallback>
                        </Avatar>
                      ) : null}
                    </div>
                  </div>
                ) : null}
              </div>
            ))}
          </div>
        </div>
      ))}
    </div>
  )
}

export { KanbanBoard }
export type {
  KanbanBoardProps,
  KanbanColumn,
  KanbanCard,
  KanbanAssignee,
}

This exact file lands in your project at components/ui/kanban-board.tsx. It is yours to edit.