Skip to main content
Back to Blog
Tutorials
4 min read
November 9, 2024

How to Build a Kanban Board in React

Build a Trello-style Kanban board in React with columns, cards, drag-and-drop reordering, and persistent state.

Ryel Banfield

Founder & Lead Developer

A Kanban board is a classic project management tool. Here is how to build one with drag-and-drop.

Step 1: Install Dependencies

pnpm add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

Step 2: Define Types

// types/kanban.ts
export interface Card {
  id: string;
  title: string;
  description?: string;
  priority: "low" | "medium" | "high";
  assignee?: string;
}

export interface Column {
  id: string;
  title: string;
  cards: Card[];
}

Step 3: Board State Management

"use client";

import { useState, useCallback } from "react";
import type { Column, Card } from "@/types/kanban";

const initialColumns: Record<string, Column> = {
  todo: { id: "todo", title: "To Do", cards: [] },
  "in-progress": { id: "in-progress", title: "In Progress", cards: [] },
  review: { id: "review", title: "Review", cards: [] },
  done: { id: "done", title: "Done", cards: [] },
};

export function useKanbanBoard() {
  const [columns, setColumns] = useState(initialColumns);

  const addCard = useCallback((columnId: string, card: Card) => {
    setColumns((prev) => ({
      ...prev,
      [columnId]: {
        ...prev[columnId],
        cards: [...prev[columnId].cards, card],
      },
    }));
  }, []);

  const moveCard = useCallback(
    (fromColumn: string, toColumn: string, fromIndex: number, toIndex: number) => {
      setColumns((prev) => {
        const updated = { ...prev };
        const fromCards = [...updated[fromColumn].cards];
        const [moved] = fromCards.splice(fromIndex, 1);

        if (fromColumn === toColumn) {
          fromCards.splice(toIndex, 0, moved);
          updated[fromColumn] = { ...updated[fromColumn], cards: fromCards };
        } else {
          const toCards = [...updated[toColumn].cards];
          toCards.splice(toIndex, 0, moved);
          updated[fromColumn] = { ...updated[fromColumn], cards: fromCards };
          updated[toColumn] = { ...updated[toColumn], cards: toCards };
        }

        return updated;
      });
    },
    []
  );

  const deleteCard = useCallback((columnId: string, cardId: string) => {
    setColumns((prev) => ({
      ...prev,
      [columnId]: {
        ...prev[columnId],
        cards: prev[columnId].cards.filter((c) => c.id !== cardId),
      },
    }));
  }, []);

  return { columns, addCard, moveCard, deleteCard };
}

Step 4: Kanban Board Component

"use client";

import {
  DndContext,
  DragOverlay,
  closestCorners,
  PointerSensor,
  useSensor,
  useSensors,
} from "@dnd-kit/core";
import {
  SortableContext,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { useState } from "react";
import { useKanbanBoard } from "./useKanbanBoard";
import { KanbanColumn } from "./KanbanColumn";
import { KanbanCard } from "./KanbanCard";
import type { Card } from "@/types/kanban";

export function KanbanBoard() {
  const { columns, addCard, moveCard } = useKanbanBoard();
  const [activeCard, setActiveCard] = useState<Card | null>(null);

  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
  );

  function findColumn(cardId: string) {
    return Object.values(columns).find((col) =>
      col.cards.some((c) => c.id === cardId)
    );
  }

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCorners}
      onDragStart={(event) => {
        const col = findColumn(event.active.id as string);
        const card = col?.cards.find((c) => c.id === event.active.id);
        if (card) setActiveCard(card);
      }}
      onDragEnd={(event) => {
        setActiveCard(null);
        const { active, over } = event;
        if (!over) return;

        const fromCol = findColumn(active.id as string);
        const toCol =
          findColumn(over.id as string) ||
          columns[over.id as string];

        if (!fromCol || !toCol) return;

        const fromIndex = fromCol.cards.findIndex((c) => c.id === active.id);
        const toIndex = toCol.cards.findIndex((c) => c.id === over.id);

        moveCard(fromCol.id, toCol.id, fromIndex, toIndex === -1 ? 0 : toIndex);
      }}
    >
      <div className="flex gap-4 overflow-x-auto p-4">
        {Object.values(columns).map((column) => (
          <SortableContext
            key={column.id}
            items={column.cards.map((c) => c.id)}
            strategy={verticalListSortingStrategy}
          >
            <KanbanColumn column={column} onAddCard={addCard} />
          </SortableContext>
        ))}
      </div>

      <DragOverlay>
        {activeCard ? <KanbanCard card={activeCard} isOverlay /> : null}
      </DragOverlay>
    </DndContext>
  );
}

Step 5: Column Component

"use client";

import { useDroppable } from "@dnd-kit/core";
import { Plus } from "lucide-react";
import { useState } from "react";
import { KanbanCard } from "./KanbanCard";
import type { Column, Card } from "@/types/kanban";

export function KanbanColumn({
  column,
  onAddCard,
}: {
  column: Column;
  onAddCard: (columnId: string, card: Card) => void;
}) {
  const [isAdding, setIsAdding] = useState(false);
  const [newTitle, setNewTitle] = useState("");
  const { setNodeRef } = useDroppable({ id: column.id });

  function handleAdd() {
    if (!newTitle.trim()) return;
    onAddCard(column.id, {
      id: crypto.randomUUID(),
      title: newTitle.trim(),
      priority: "medium",
    });
    setNewTitle("");
    setIsAdding(false);
  }

  return (
    <div
      ref={setNodeRef}
      className="w-72 shrink-0 rounded-xl bg-gray-100 p-3 dark:bg-gray-800"
    >
      <div className="mb-3 flex items-center justify-between">
        <h3 className="text-sm font-semibold">{column.title}</h3>
        <span className="rounded-full bg-gray-200 px-2 py-0.5 text-xs dark:bg-gray-700">
          {column.cards.length}
        </span>
      </div>

      <div className="space-y-2">
        {column.cards.map((card) => (
          <KanbanCard key={card.id} card={card} />
        ))}
      </div>

      {isAdding ? (
        <div className="mt-2">
          <input
            autoFocus
            value={newTitle}
            onChange={(e) => setNewTitle(e.target.value)}
            onKeyDown={(e) => e.key === "Enter" && handleAdd()}
            placeholder="Card title..."
            className="w-full rounded-lg border bg-white p-2 text-sm dark:border-gray-600 dark:bg-gray-900"
          />
          <div className="mt-1 flex gap-1">
            <button
              onClick={handleAdd}
              className="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
            >
              Add
            </button>
            <button
              onClick={() => setIsAdding(false)}
              className="rounded px-3 py-1 text-xs hover:bg-gray-200 dark:hover:bg-gray-700"
            >
              Cancel
            </button>
          </div>
        </div>
      ) : (
        <button
          onClick={() => setIsAdding(true)}
          className="mt-2 flex w-full items-center gap-1 rounded-lg p-2 text-sm text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700"
        >
          <Plus className="h-4 w-4" /> Add card
        </button>
      )}
    </div>
  );
}

Step 6: Card Component

"use client";

import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import type { Card } from "@/types/kanban";

const priorityColors = {
  low: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
  medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300",
  high: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
};

export function KanbanCard({
  card,
  isOverlay,
}: {
  card: Card;
  isOverlay?: boolean;
}) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: card.id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
  };

  return (
    <div
      ref={setNodeRef}
      style={style}
      className={`rounded-lg border bg-white p-3 shadow-sm dark:border-gray-600 dark:bg-gray-900 ${
        isOverlay ? "shadow-xl ring-2 ring-blue-500" : ""
      }`}
    >
      <div className="flex items-start gap-2">
        <button
          {...attributes}
          {...listeners}
          className="mt-0.5 cursor-grab text-gray-400 active:cursor-grabbing"
        >
          <GripVertical className="h-4 w-4" />
        </button>
        <div className="flex-1">
          <p className="text-sm font-medium">{card.title}</p>
          {card.description && (
            <p className="mt-1 text-xs text-gray-500">{card.description}</p>
          )}
          <span
            className={`mt-2 inline-block rounded-full px-2 py-0.5 text-[10px] font-medium ${
              priorityColors[card.priority]
            }`}
          >
            {card.priority}
          </span>
        </div>
      </div>
    </div>
  );
}

Want a Custom Project Management Tool?

We build web applications with drag-and-drop interfaces, real-time collaboration, and intuitive UX. Contact us to discuss your project.

kanbandrag and dropReactproject managementtutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles