Build a kanban board in React with drag and drop

Build a React kanban board with drag and drop using dnd-kit: sortable cards, moves between columns, keyboard dragging, announcements and optimistic updates.

By MiniDev21 min read

A React kanban board with drag and drop is a list of columns, each holding an ordered array of card IDs, rendered inside one DndContext from @dnd-kit/core with a SortableContext per column. onDragOver moves a card into another column while it is being dragged, onDragEnd reorders it with arrayMove and saves the result, and a KeyboardSensor with sortableKeyboardCoordinates makes the whole thing work without a mouse.

This guide builds that board with dnd-kit and the free MiniDev UI KanbanColumn, IssueCard, DueDateChip, PriorityPicker and AssignPicker. It covers the state shape, moving cards between columns, keyboard and screen reader support, and optimistic updates with rollback. The kanban board glossary entry explains the workflow side if you need it.

Install

bash
npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

npx shadcn@latest add https://ui.minidev.pro/r/kanban-column.json
npx shadcn@latest add https://ui.minidev.pro/r/issue-card.json
npx shadcn@latest add https://ui.minidev.pro/r/due-date-chip.json
npx shadcn@latest add https://ui.minidev.pro/r/priority-picker.json
npx shadcn@latest add https://ui.minidev.pro/r/assign-picker.json

Add the token stylesheet once (@import "minidev-ui-kit/styles.css", or a copy of styles.css). There is also a KanbanBoard component that takes columns of { id, title, cards } and renders a complete read only board. Use it for previews, reports and empty states; for dragging you need control over each card's wrapper, so this guide composes the board from KanbanColumn and IssueCard.

Model the board state

Store cards once, by ID, and keep only ordered ID arrays in the columns. A move then changes two small arrays and never copies card data:

lib/board.tsts
export type Priority = "low" | "medium" | "high" | "urgent"

export type Issue = {
  id: string
  key: string // "ENG-142"
  title: string
  priority: Priority
  assigneeId?: string
  due?: string // ISO date
}

export type Column = { id: string; title: string; cardIds: string[] }
export type Board = { columns: Column[]; cards: Record<string, Issue> }

export function findColumnId(board: Board, id: string) {
  if (board.columns.some((c) => c.id === id)) return id
  return board.columns.find((c) => c.cardIds.includes(id))?.id
}

findColumnId accepts either a card ID or a column ID, because during a drag the thing under the pointer can be a card or an empty column.

Render columns and cards

KanbanColumn takes a title, an optional count (shown as a badge) and children. IssueCard takes id (the display key), title, status and priority as strings. The shipped card ends with a placeholder line ("Assigned to Design Eng"), so in your copy replace it with a slot for real metadata:

components/ui/issue-card.tsxtsx
import type { ReactNode } from "react"

// add children?: ReactNode to the props, then replace the placeholder <p> with:
{children ? <div className="mt-3 flex items-center gap-2">{children}</div> : null}

DueDateChip takes a preformatted date string and an overdue flag, which switches the border and icon to the danger color. Compute both yourself so the formatting and the definition of overdue live in one place.

Make cards sortable

Each card is wrapped in an element that calls useSortable. The hook returns a ref setter, the drag listeners, ARIA attributes (role="button", tabIndex, aria-roledescription="sortable", aria-describedby) and the current transform:

app/board/sortable-issue.tsxtsx
"use client"
import { useSortable } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { IssueCard } from "@/components/ui/issue-card"
import { DueDateChip } from "@/components/ui/due-date-chip"
import { cn } from "@/lib/utils"
import type { Issue } from "@/lib/board"

export const PRIORITY_LABEL = { low: "Low", medium: "Medium", high: "High", urgent: "Urgent" }
const short = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })

export function SortableIssue({ issue, status }: { issue: Issue; status: string }) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: issue.id })
  const overdue = issue.due ? new Date(issue.due).getTime() < Date.now() : false

  return (
    <div
      ref={setNodeRef}
      style={{ transform: CSS.Transform.toString(transform), transition }}
      className={cn("rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-accent", isDragging && "opacity-40")}
      {...attributes}
      {...listeners}
    >
      <IssueCard id={issue.key} title={issue.title} status={status} priority={PRIORITY_LABEL[issue.priority]}>
        {issue.due ? <DueDateChip date={short.format(new Date(issue.due))} overdue={overdue} /> : null}
      </IssueCard>
    </div>
  )
}

While a card is dragged, its original spot stays in the list at 40% opacity so the gap shows where it will land, and a DragOverlay (below) renders the copy that follows the pointer.

Accept drops in every column

A SortableContext only knows about its items, so an empty column has nothing to collide with. Register the column body itself with useDroppable, using the column ID:

app/board/board-column.tsxtsx
"use client"
import { useDroppable } from "@dnd-kit/core"
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"
import { KanbanColumn } from "@/components/ui/kanban-column"
import { cn } from "@/lib/utils"
import type { Column, Issue } from "@/lib/board"
import { SortableIssue } from "./sortable-issue"

export function BoardColumn({ column, cards }: { column: Column; cards: Issue[] }) {
  const { setNodeRef, isOver } = useDroppable({ id: column.id })
  return (
    <KanbanColumn title={column.title} count={cards.length} className="shrink-0">
      <SortableContext id={column.id} items={column.cardIds} strategy={verticalListSortingStrategy}>
        <div ref={setNodeRef} className={cn("flex min-h-24 flex-col gap-2 rounded-lg", isOver && cards.length === 0 && "bg-accent-soft")}>
          {cards.map((issue) => (
            <SortableIssue key={issue.id} issue={issue} status={column.title} />
          ))}
        </div>
      </SortableContext>
    </KanbanColumn>
  )
}

Move cards within and between columns

The board owns the state and the three handlers. Take a snapshot when the drag starts, move the card to the new column in onDragOver so the target column opens a gap live, reorder in onDragEnd, and restore the snapshot if the drag is cancelled:

app/board/board.tsxtsx
"use client"
import * as React from "react"
import {
  DndContext,
  DragOverlay,
  KeyboardSensor,
  PointerSensor,
  closestCorners,
  useSensor,
  useSensors,
  type DragEndEvent,
  type DragOverEvent,
  type DragStartEvent,
} from "@dnd-kit/core"
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"
import { IssueCard } from "@/components/ui/issue-card"
import { findColumnId, type Board as BoardData } from "@/lib/board"
import { BoardColumn } from "./board-column"
import { PRIORITY_LABEL } from "./sortable-issue"

export function Board({ initial }: { initial: BoardData }) {
  const [board, setBoard] = React.useState(initial)
  const [activeId, setActiveId] = React.useState<string | null>(null)
  const snapshot = React.useRef<BoardData | null>(null)

  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
  )

  function onDragStart({ active }: DragStartEvent) {
    snapshot.current = board
    setActiveId(String(active.id))
  }

  function onDragOver({ active, over }: DragOverEvent) {
    if (!over) return
    const from = findColumnId(board, String(active.id))
    const to = findColumnId(board, String(over.id))
    if (!from || !to || from === to) return
    setBoard((b) => {
      const target = b.columns.find((c) => c.id === to)!
      const overIndex = target.cardIds.indexOf(String(over.id))
      const index = overIndex === -1 ? target.cardIds.length : overIndex
      return {
        ...b,
        columns: b.columns.map((c) => {
          if (c.id === from) return { ...c, cardIds: c.cardIds.filter((id) => id !== active.id) }
          if (c.id === to) return { ...c, cardIds: [...c.cardIds.slice(0, index), String(active.id), ...c.cardIds.slice(index)] }
          return c
        }),
      }
    })
  }

  function onDragEnd({ active, over }: DragEndEvent) {
    setActiveId(null)
    const prev = snapshot.current
    if (!over || !prev) return onDragCancel()
    const colId = findColumnId(board, String(active.id))
    const col = board.columns.find((c) => c.id === colId)!
    const from = col.cardIds.indexOf(String(active.id))
    const to = col.cardIds.indexOf(String(over.id))
    const next =
      to !== -1 && from !== to
        ? { ...board, columns: board.columns.map((c) => (c.id === colId ? { ...c, cardIds: arrayMove(c.cardIds, from, to) } : c)) }
        : board
    setBoard(next)
    void persistMove(String(active.id), prev, next)
  }

  function onDragCancel() {
    setActiveId(null)
    if (snapshot.current) setBoard(snapshot.current)
  }

  // persistMove and announcements: see the next sections

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCorners}
      onDragStart={onDragStart}
      onDragOver={onDragOver}
      onDragEnd={onDragEnd}
      onDragCancel={onDragCancel}
    >
      <div className="flex items-start gap-3 overflow-x-auto pb-4">
        {board.columns.map((col) => (
          <BoardColumn key={col.id} column={col} cards={col.cardIds.map((id) => board.cards[id])} />
        ))}
      </div>
      <DragOverlay>
        {activeId ? (
          <IssueCard
            id={board.cards[activeId].key}
            title={board.cards[activeId].title}
            status={board.columns.find((c) => c.cardIds.includes(activeId))?.title}
            priority={PRIORITY_LABEL[board.cards[activeId].priority]}
            className="shadow-overlay"
          />
        ) : null}
      </DragOverlay>
    </DndContext>
  )
}
  • closestCorners works better than the default rectIntersection for stacked lists in side by side columns, because it measures to the corners of each droppable rather than requiring overlap.
  • arrayMove(array, from, to) returns a new array with one item moved; it never mutates.
  • activationConstraint: { distance: 6 } means a drag starts only after the pointer moves 6px, so a plain click on the card still opens it and clicks on buttons inside it still work.
  • On touch screens, the PointerSensor competes with scrolling. Either set touch-action: none on cards (they then cannot be used to scroll) or use MouseSensor plus TouchSensor with activationConstraint: { delay: 200, tolerance: 5 } for press and hold.

Keyboard dragging and screen readers

With the KeyboardSensor registered, every card is focusable and draggable from the keyboard: Space or Enter picks it up, arrow keys move it, Space or Enter drops it, and Escape cancels (which calls onDragCancel and restores the snapshot). sortableKeyboardCoordinates moves the card to the next sortable item in the arrow's direction, which crosses into neighboring columns with the collision detection above. For very large boards, dnd-kit's multiple containers example ships a custom coordinate getter you can adopt.

dnd-kit also renders a visually hidden live region and instructions. The defaults talk about "draggable item 3"; replace them with names people recognize through the accessibility prop:

tsx
import type { Announcements } from "@dnd-kit/core"

const name = (id: string | number) => board.cards[String(id)]?.key ?? "item"
const column = (id: string | number) => board.columns.find((c) => c.id === findColumnId(board, String(id)))?.title ?? "the board"

const announcements: Announcements = {
  onDragStart: ({ active }) => "Picked up " + name(active.id) + ".",
  onDragOver: ({ active, over }) => (over ? name(active.id) + " is over " + column(over.id) + "." : undefined),
  onDragEnd: ({ active, over }) => (over ? "Dropped " + name(active.id) + " in " + column(over.id) + "." : "Dropped " + name(active.id) + "."),
  onDragCancel: ({ active }) => "Move cancelled. " + name(active.id) + " is back where it started.",
}

<DndContext
  sensors={sensors}
  collisionDetection={closestCorners}
  accessibility={{
    announcements,
    screenReaderInstructions: {
      draggable: "To move an issue, press Space or Enter. Use the arrow keys to move it within and between columns. Press Space or Enter to drop it, or Escape to cancel.",
    },
  }}
  onDragStart={onDragStart}
  onDragOver={onDragOver}
  onDragEnd={onDragEnd}
  onDragCancel={onDragCancel}
>
  {/* columns and DragOverlay as before */}
</DndContext>

Dragging should never be the only way to move a card. WCAG 2.2 (success criterion 2.5.7) asks for a single pointer alternative. Add a "Move to" menu or a status field in the card's detail view that calls the same persistMove path.

Optimistic updates with rollback

The board already shows the new position before the server responds, which is the optimistic UI pattern. What remains is saving the move and undoing it if the save fails:

tsx
async function persistMove(cardId: string, prev: BoardData, next: BoardData) {
  const col = next.columns.find((c) => c.cardIds.includes(cardId))!
  const i = col.cardIds.indexOf(cardId)
  const before = prev.columns.find((c) => c.cardIds.includes(cardId))!
  if (before.id === col.id && before.cardIds.indexOf(cardId) === i) return // nothing changed

  try {
    const res = await fetch("/api/issues/" + cardId + "/move", {
      method: "PATCH",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ columnId: col.id, afterId: col.cardIds[i - 1] ?? null, beforeId: col.cardIds[i + 1] ?? null }),
    })
    if (!res.ok) throw new Error("Move failed")
  } catch {
    setBoard(prev)
    showError("Could not move " + next.cards[cardId].key + ". It is back in " + before.title + ".")
  }
}
  • Send the neighbors, not an index. The server computes a sort key between afterId and beforeId (a fractional or lexicographic rank), so a move writes one row instead of renumbering the whole column.
  • Restoring the snapshot is right for a single user board. When several people edit the same board, refetch it on failure instead, so you do not wipe out someone else's move.
  • Tell the user when a rollback happens. A card jumping back without explanation looks like a bug. The toast notifications guide covers the message.
  • If you use TanStack Query, the same flow maps onto a mutation with onMutate (snapshot and apply), onError (restore) and onSettled (invalidate).

Edit priority and assignee

Open a side panel or popover when a card is clicked, and edit fields there rather than on the card, where controls would fight with dragging. PriorityPicker is a radiogroup of Low, Medium, High and Urgent with value and onChange; AssignPicker takes people of { id, name }, the selected value and an onChange with the person's ID:

tsx
<PriorityPicker
  value={issue.priority}
  onChange={(p) => updateIssue(issue.id, { priority: p as Priority })}
/>
<AssignPicker
  people={members}
  value={issue.assigneeId}
  onChange={(id) => updateIssue(issue.id, { assigneeId: id })}
/>

updateIssue follows the same optimistic pattern: patch board.cards[id] locally, send the request, restore on failure. Both pickers render every option as a focusable button, which is fine for four priorities and a small team. For long member lists, put a search field above AssignPicker or switch to a combobox.

Large boards and performance

A board with a few hundred cards stays smooth if dragging does not re-render every card on every pointer move. dnd-kit moves items with CSS transforms, and sortable items mostly update when the item under the pointer changes, so most of the cost is in your own components. A few habits keep it that way:

  • Wrap SortableIssue in React.memo and pass it the issue object from board.cards, which keeps its identity when other columns change.
  • Keep the DragOverlay content light. It mounts on every drag start and is repositioned on every move, so render the card without popovers, avatars that fetch, or anything that measures layout.
  • Leave auto scrolling on (the default). When a dragged card nears the edge of the horizontally scrolling board or a tall column, dnd-kit scrolls that container for you.
  • Cap column height with max-h and overflow-y-auto so long columns scroll on their own instead of stretching the page.
  • Virtualizing columns with hundreds of cards is possible, but sortable items that are not mounted cannot be measured; paginate or collapse done work ("Show 200 more") first.

Components used in this guide

All free and MIT licensed; the rest are in the workflow category. A kanban board usually sits inside an app shell, covered in the sidebar layout guide, and pairs well with a command palette for jumping to an issue by key. If you would rather hand off the whole product, the MiniDev studio builds complete apps with this kit.

Kanban ColumnuiA single Kanban lane with a title, an optional count badge and a slot for any cards you render as children. Compose several to build your own React task board.npx shadcn@latest add ui.minidev.pro/r/kanban-column.jsonIssue CarduiA compact issue tracker card with a monospace ticket key, status and priority badges, a title and an assignee line. Fits Linear or Jira style boards.npx shadcn@latest add ui.minidev.pro/r/issue-card.jsonKanban BoarduiA horizontally scrolling Kanban board built from column data: each column shows its title, a card count and stacked cards with a title and optional meta line.npx shadcn@latest add ui.minidev.pro/r/kanban-board.jsonDue Date ChipuiSmall Tailwind due date chip with a calendar icon that switches to a red border and icon when overdue. Use it on task cards, tickets and boards.npx shadcn@latest add ui.minidev.pro/r/due-date-chip.jsonPriority PickeruiA radio group of priority chips, Low, Medium, High and Urgent, each with a colored dot and aria-checked state. Built for issue trackers, tasks and ticket forms.npx shadcn@latest add ui.minidev.pro/r/priority-picker.jsonAssign PickeruiAssignee picker for tasks and tickets: a listbox of people with initials avatars, where the selected person is highlighted and choices report their id.npx shadcn@latest add ui.minidev.pro/r/assign-picker.json
bash
npx shadcn@latest add https://ui.minidev.pro/r/<name>.json
# or the package
npm i minidev-ui-kit

Components used in this guide

Frequently asked questions

Which drag and drop library should I use for a React kanban board?

dnd-kit (@dnd-kit/core and @dnd-kit/sortable) is a solid default: it is maintained, supports multiple sortable containers, and has keyboard dragging and screen reader announcements built in. react-beautiful-dnd is deprecated.

How do I drop a card into an empty column?

Register the column body with useDroppable({ id: column.id }) in addition to its SortableContext. Without it, an empty column has no droppable item for collision detection to find.

Can users move cards with the keyboard?

Yes. Add useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }). Space or Enter picks a card up, arrow keys move it, Space or Enter drops it and Escape cancels. Customize the announcements so screen readers hear issue names.

How should I store card order in the database?

Give each card a sort key and, on a move, compute a key between its new neighbors (fractional indexing). That updates one row per move instead of rewriting every position in the column.

More guides