EmpireUI
Get Pro
← Blog9 min read#kanban#drag drop#react

Kanban Board in React: Drag-and-Drop Columns With dnd-kit

Build a fully working kanban board in React using dnd-kit — drag cards across columns, handle sorting, and wire up real state updates without losing your mind.

Developer coding a kanban task board on a laptop screen

Why dnd-kit and Not react-beautiful-dnd

If you've been building drag-and-drop UIs in React for more than a year, you probably started with react-beautiful-dnd. Reasonable choice — Atlassian wrote it, it's polished, and the docs are solid. But it's also effectively unmaintained since 2022, and it has a hard dependency on the HTML5 drag-and-drop API, which means zero pointer-event support and broken behavior on touch devices in 2026.

dnd-kit landed on the scene and solved basically all of that. It's modular, framework-agnostic at the sensor layer, and it doesn't touch the DOM for its drag detection — it works through pointer events. That means it works on mobile, in iframes, and inside custom scroll containers without hacks.

Honestly, the migration cost from react-beautiful-dnd to dnd-kit is not trivial. The mental model is different. But if you're starting fresh — and for a kanban board you almost certainly are — just start with dnd-kit. You'll thank yourself.

One more thing — dnd-kit ships a @dnd-kit/sortable preset that wraps 80% of the boilerplate for sortable lists. Your kanban board is basically two nested sortable lists (columns and cards), so that preset does a lot of the heavy lifting.

Installing and Setting Up the Packages

You need three packages. Core, sortable preset, and utilities — that's it.

npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

dnd-kit v6 (released late 2024) brought a cleaner collision detection API and fixed a long-standing issue with closestCorners in nested droppables. Make sure you're on v6+ before starting — earlier versions will frustrate you with edge cases in the column-to-column drop logic.

Worth noting: you don't need @dnd-kit/modifiers to build a kanban board, but the restrictToWindowEdges modifier is handy if you want to prevent cards from being dragged off-screen. Add it later when you're polishing.

Your entry point wraps everything in a DndContext. That context owns the drag state, fires events, and coordinates between your SortableContext instances inside each column. Keep that hierarchy in mind — it drives almost every architectural decision you'll make.

Data Model and State Shape

Before writing a single dnd-kit line, get your state shape right. This trips up most people. A kanban board has two entity types: columns and cards. You want them stored separately, with cards referencing their column by ID.

type Card = {
  id: string;
  title: string;
  columnId: string;
};

type Column = {
  id: string;
  title: string;
};

const [columns, setColumns] = useState<Column[]>([
  { id: 'todo', title: 'To Do' },
  { id: 'inprogress', title: 'In Progress' },
  { id: 'done', title: 'Done' },
]);

const [cards, setCards] = useState<Card[]>([
  { id: 'card-1', title: 'Set up repo', columnId: 'todo' },
  { id: 'card-2', title: 'Write tests', columnId: 'todo' },
  { id: 'card-3', title: 'Build UI', columnId: 'inprogress' },
]);

Flat arrays work better than a nested { [columnId]: Card[] } map here. Why? Because dnd-kit sortable needs a flat list of IDs for each SortableContext, and you'll be computing that per-column anyway. Keeping cards flat means a single state update instead of nested immutable surgery on every drop.

In practice, you'll derive the per-column card lists with a selector — cards.filter(c => c.columnId === col.id) — and memoize it with useMemo. Nothing fancy. Just don't store derived data in state.

Quick aside: if you're pulling data from a real backend, normalize it with something like Zustand or React Query before it hits your drag state. Mixing server state and ephemistic drag state in useState works fine for prototypes but gets messy fast when you add optimistic updates.

Building the DndContext and Column Layout

Here's the board shell. It wraps everything in DndContext, handles the two events you actually care about (onDragOver for live feedback and onDragEnd for committing the drop), and renders columns with their own SortableContext.

import {
  DndContext,
  DragOverEvent,
  DragEndEvent,
  closestCorners,
  PointerSensor,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';

export function KanbanBoard() {
  const [columns, setColumns] = useState<Column[]>(INITIAL_COLUMNS);
  const [cards, setCards] = useState<Card[]>(INITIAL_CARDS);

  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: { distance: 8 }, // px before drag starts
    })
  );

  function handleDragOver(event: DragOverEvent) {
    const { active, over } = event;
    if (!over) return;

    const activeCard = cards.find(c => c.id === active.id);
    const overColumn = columns.find(c => c.id === over.id);
    const overCard = cards.find(c => c.id === over.id);

    if (!activeCard) return;

    // Dragging over a column directly
    if (overColumn && activeCard.columnId !== overColumn.id) {
      setCards(prev =>
        prev.map(c =>
          c.id === activeCard.id ? { ...c, columnId: overColumn.id } : c
        )
      );
    }

    // Dragging over another card — move to that card's column
    if (overCard && activeCard.columnId !== overCard.columnId) {
      setCards(prev =>
        prev.map(c =>
          c.id === activeCard.id ? { ...c, columnId: overCard.columnId } : c
        )
      );
    }
  }

  function handleDragEnd(event: DragEndEvent) {
    // Reorder within the same column if needed
    // ... arrayMove logic here
  }

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCorners}
      onDragOver={handleDragOver}
      onDragEnd={handleDragEnd}
    >
      <div className="flex gap-4 p-6">
        {columns.map(col => (
          <KanbanColumn
            key={col.id}
            column={col}
            cards={cards.filter(c => c.columnId === col.id)}
          />
        ))}
      </div>
    </DndContext>
  );
}

The distance: 8 activation constraint is important. Without it, any click on a card immediately triggers a drag, which destroys click interactions like opening a card modal. 8px of movement before drag activation feels natural and won't block normal clicks.

Notice closestCorners for collision detection. You might be tempted to use closestCenter, but it has a weird edge case in vertical lists where hovering near the bottom of one column incorrectly detects the next column's first card. closestCorners handles this better for kanban layouts specifically.

The onDragOver handler does the cross-column move *live* as the user drags. That's what gives you the ghost-position feedback. onDragEnd is where you commit the final order — especially within-column reordering using arrayMove from @dnd-kit/sortable.

The Sortable Card Component

Each card needs to be a sortable item. dnd-kit's useSortable hook gives you everything — drag handle ref, transform styles, and a boolean to know if this card is currently being dragged.

import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

type CardProps = {
  card: Card;
};

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

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

  return (
    <div
      ref={setNodeRef}
      style={style}
      {...attributes}
      {...listeners}
      className="bg-white dark:bg-zinc-800 rounded-lg p-3 shadow-sm
                 border border-zinc-200 dark:border-zinc-700
                 cursor-grab active:cursor-grabbing"
    >
      <p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
        {card.title}
      </p>
    </div>
  );
}

The opacity: 0.4 on isDragging is your placeholder effect — the original card fades in place while a drag overlay follows the cursor. If you want to show a proper drag overlay (floating above everything else), you add a DragOverlay component inside DndContext and render a clone of the card inside it. That's optional but makes the UX feel noticeably more polished.

Look, spreading {...listeners} on the entire card div makes the whole card draggable. If you want a drag handle icon instead, move {...listeners} to just the handle element and leave {...attributes} on the outer div. Common pattern for cards that also have clickable content.

That said, make sure you're not putting {...listeners} on buttons or inputs inside the card. dnd-kit will intercept pointer events and your buttons won't fire. Either use event.stopPropagation() in button handlers or structure the card so interactive elements sit outside the listener scope.

Handling Within-Column Reordering

Cross-column moves work through onDragOver. Within-column reordering — moving card 2 above card 1 in the same column — needs arrayMove in onDragEnd.

import { arrayMove } from '@dnd-kit/sortable';

function handleDragEnd(event: DragEndEvent) {
  const { active, over } = event;
  if (!over || active.id === over.id) return;

  const activeCard = cards.find(c => c.id === active.id);
  const overCard = cards.find(c => c.id === over.id);

  if (!activeCard || !overCard) return;
  if (activeCard.columnId !== overCard.columnId) return;

  // Same column — reorder
  const columnCards = cards.filter(c => c.columnId === activeCard.columnId);
  const otherCards = cards.filter(c => c.columnId !== activeCard.columnId);

  const oldIndex = columnCards.findIndex(c => c.id === active.id);
  const newIndex = columnCards.findIndex(c => c.id === over.id);

  const reordered = arrayMove(columnCards, oldIndex, newIndex);
  setCards([...otherCards, ...reordered]);
}

The key insight is separating cards by column, running arrayMove on just that column's cards, then merging back. Don't try to arrayMove the flat array directly — you'll get incorrect indices the moment columns have different card counts.

If you're persisting to a backend, fire your API call here in handleDragEnd, not in handleDragOver. The over event fires continuously during a drag — you don't want 50 API calls per second. handleDragEnd fires exactly once.

Worth noting: if you also want draggable columns (not just cards), you'd nest another SortableContext at the column level with a horizontalListSortingStrategy. The logic mirrors what you've already built — just add a second entity type to your drag end handler and branch on whether active.id is a column ID or a card ID.

Styling the Board and Adding Visual Feedback

A kanban board that works is table stakes. One that feels good to use is the thing people actually notice. The styling decisions that matter most are: a visible drop zone when a column is active, a smooth transition on card movement, and a drag overlay that looks like the real card.

For the active column highlight, you can use dnd-kit's useDroppable hook in your column component, or just use isDraggingOver from the SortableContext hook. A 2px border change and a subtle background tint — something like bg-zinc-100 to bg-blue-50 — is all you need. Don't go overboard with animations here; they compete with the drag motion itself.

The transition style from useSortable already handles the sliding animation when cards make room for the incoming card. You don't need to add your own. The default spring curve is good. If you want to tweak it, pass a transition config to useSortable — but honestly the default is fine for 95% of cases.

For component styles that complement the board aesthetic, check out what Empire UI has in its glassmorphism and neobrutalism component libraries. A neobrutalism card style — bold border, flat shadow — actually pairs really well with kanban because the visual weight helps cards feel grabbable. Alternatively, the muted depth of glassmorphism components works great for dark-mode boards.

One more thing — add will-change: transform to your card style when it's being dragged. It tells the browser to promote the element to its own compositor layer, which prevents the occasional paint jank you'll see on lower-end devices when dragging across columns with many items.

FAQ

Does dnd-kit work with React 19?

Yes, dnd-kit v6+ is compatible with React 19. No breaking changes in the core API — you can upgrade without touching your drag implementation.

How do I prevent dragging onto the same position?

Check active.id === over.id at the top of your handleDragEnd handler and return early. That's the standard guard and it's all you need.

Can I add keyboard accessibility to dnd-kit drag-and-drop?

Yes — add KeyboardSensor alongside PointerSensor in your useSensors call. dnd-kit has built-in keyboard navigation support that follows WCAG 2.1 motion patterns.

What's the difference between onDragOver and onDragEnd in dnd-kit?

onDragOver fires continuously while dragging (use it for live UI feedback like moving a card to a new column preview). onDragEnd fires once on drop — that's where you commit state and call APIs.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Kanban Board in React: Drag-and-Drop Task Management UIFile Upload in React: Drag & Drop, Progress Bar and ValidationDrag-and-Drop Kanban in React: dnd-kit Full ImplementationDrag and Drop in React 2026: dnd-kit vs react-beautiful-dnd