Kanban Board Column

A multi-column kanban board with issue cards showing title, labels, assignee avatar, and priority indicator. Cards support HTML5 drag-and-drop reordering within and across columns, with an 'Add card' affordance per column.

Tasks & IssuesInspired by Linear
Live PreviewInteractive
Linear · Board
Todo2

Fix sparkline overflow on mobile

bug
MK

Add empty state to notification center

design
In Progress1

Editable cell keyboard navigation

feature
SC
Done1

Ship kanban drag reorder

LP

Drag a card to reorder within or across columns · “Add card” appends a new issue

KanbanBoard.tsx
// Dependencies: react ^18, lucide-react
import React, { useState } from 'react';
import { Plus } from 'lucide-react';

function cn(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(' ');
}

export type KanbanLabel = { id: string; name: string; color: string };

export type KanbanCard = {
  id: string;
  title: string;
  labels?: KanbanLabel[];
  assignee?: { name: string; initials: string; color: string };
  priority?: 'urgent' | 'high' | 'medium' | 'low';
};

export type KanbanColumnData = {
  id: string;
  title: string;
  cards: KanbanCard[];
};

type Props = {
  initialColumns: KanbanColumnData[];
};

const PRIORITY_COLOR: Record<string, string> = {
  urgent: '#ef4444',
  high: '#f97316',
  medium: '#eab308',
  low: '#3b82f6',
};

let nextCardId = 0;

export function KanbanBoard({ initialColumns }: Props) {
  const [columns, setColumns] = useState<KanbanColumnData[]>(initialColumns);
  const [dragCard, setDragCard] = useState<{ colId: string; cardId: string } | null>(null);

  const addCard = (colId: string) => {
    setColumns((prev) =>
      prev.map((col) =>
        col.id === colId
          ? {
              ...col,
              cards: [
                ...col.cards,
                { id: `new-${Date.now()}-${++nextCardId}`, title: 'New issue' },
              ],
            }
          : col
      )
    );
  };

  const handleDrop = (colId: string, targetCardId: string | null) => {
    if (!dragCard) return;
    setColumns((prev) => {
      const sourceCol = prev.find((c) => c.id === dragCard.colId);
      const draggedCard = sourceCol?.cards.find((c) => c.id === dragCard.cardId);
      if (!sourceCol || !draggedCard) return prev;

      let next = prev.map((col) =>
        col.id === sourceCol.id
          ? { ...col, cards: col.cards.filter((c) => c.id !== draggedCard.id) }
          : col
      );

      next = next.map((col) => {
        if (col.id !== colId) return col;
        const cards = [...col.cards];
        const targetIndex = targetCardId
          ? cards.findIndex((c) => c.id === targetCardId)
          : cards.length;
        const insertAt = targetIndex === -1 ? cards.length : targetIndex;
        cards.splice(insertAt, 0, draggedCard);
        return { ...col, cards };
      });

      return next;
    });
    setDragCard(null);
  };

  return (
    <div className="w-full flex gap-3 overflow-x-auto">
      {columns.map((col) => (
        <div
          key={col.id}
          className="w-60 shrink-0 flex flex-col bg-[#0a0a0a] border border-[#1a1a1a] rounded-xl overflow-hidden"
          onDragOver={(e) => e.preventDefault()}
          onDrop={(e) => {
            e.preventDefault();
            handleDrop(col.id, null);
          }}
        >
          {/* Column header */}
          <div className="flex items-center justify-between px-3 py-2.5 border-b border-[#1a1a1a]">
            <span className="text-xs font-semibold text-zinc-300">{col.title}</span>
            <span className="text-[11px] text-zinc-600 bg-[#151515] rounded-full px-1.5 py-0.5">
              {col.cards.length}
            </span>
          </div>

          {/* Cards */}
          <div className="flex-1 flex flex-col gap-2 p-2 min-h-[60px]">
            {col.cards.map((card) => (
              <div
                key={card.id}
                draggable
                onDragStart={() => setDragCard({ colId: col.id, cardId: card.id })}
                onDragOver={(e) => e.preventDefault()}
                onDrop={(e) => {
                  e.preventDefault();
                  e.stopPropagation();
                  handleDrop(col.id, card.id);
                }}
                className={cn(
                  'rounded-lg border border-[#1e1e1e] bg-[#111] p-2.5 flex flex-col gap-2 cursor-grab active:cursor-grabbing hover:border-[#2a2a2a] transition-colors',
                  dragCard?.cardId === card.id && 'opacity-40'
                )}
              >
                <p className="text-[13px] text-zinc-200 leading-snug">{card.title}</p>
                {card.labels && card.labels.length > 0 && (
                  <div className="flex items-center gap-1 flex-wrap">
                    {card.labels.map((label) => (
                      <span
                        key={label.id}
                        className="text-[10px] px-1.5 py-0.5 rounded-full font-medium border"
                        style={{
                          color: label.color,
                          borderColor: `${label.color}40`,
                          backgroundColor: `${label.color}15`,
                        }}
                      >
                        {label.name}
                      </span>
                    ))}
                  </div>
                )}
                <div className="flex items-center justify-between">
                  {card.priority ? (
                    <span
                      className="w-2.5 h-2.5 rounded-sm shrink-0"
                      style={{ backgroundColor: PRIORITY_COLOR[card.priority] }}
                    />
                  ) : (
                    <span />
                  )}
                  {card.assignee && (
                    <div
                      className="w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-bold text-white shrink-0"
                      style={{ backgroundColor: card.assignee.color }}
                      title={card.assignee.name}
                    >
                      {card.assignee.initials}
                    </div>
                  )}
                </div>
              </div>
            ))}
          </div>

          {/* Add card */}
          <button
            onClick={() => addCard(col.id)}
            className="flex items-center gap-1.5 px-3 py-2.5 border-t border-[#1a1a1a] text-xs text-zinc-500 hover:text-zinc-200 hover:bg-white/[0.03] transition-colors"
          >
            <Plus className="w-3.5 h-3.5" />
            Add card
          </button>
        </div>
      ))}
    </div>
  );
}

// Usage:
// const COLUMNS = [
//   {
//     id: 'todo',
//     title: 'Todo',
//     cards: [
//       { id: '1', title: 'Fix sparkline overflow', priority: 'high', assignee: { name: 'Sarah Chen', initials: 'SC', color: '#6366f1' } },
//     ],
//   },
//   { id: 'in-progress', title: 'In Progress', cards: [] },
//   { id: 'done', title: 'Done', cards: [] },
// ];
// <KanbanBoard initialColumns={COLUMNS} />

Unlock to copy

Free access to all patterns