Build a Kanban Board in React with dnd-kit (and Postgres)

A production kanban board with dnd-kit — sensors for mouse, touch and keyboard, sortable columns, fractional positions stored in Postgres, optimistic moves that roll back, and accessibility built in.

AAAayush AdhikariAugust 10, 2026 7 min read

A React kanban board built with dnd-kit needs four pieces: a DndContext with pointer and keyboard sensors, one SortableContext per column, a DragOverlay for the card being dragged, and an onDragEnd handler that computes the card's new column and position. Store positions in Postgres as fractional numbers — the midpoint between the neighbouring cards — so a move updates one row, not the whole column. Update the UI optimistically and roll back if the server refuses. This is how LetRelay's task board works.

Why dnd-kit

dnd-kit is a modular drag-and-drop toolkit for React: a small core (@dnd-kit/core), a sortable preset (@dnd-kit/sortable) and utilities. It supports mouse, touch and keyboard input through sensors, and ships accessibility features — screen-reader announcements and keyboard dragging — rather than leaving them to you. The older react-beautiful-dnd was archived by Atlassian in August 2025 and deprecated on npm (its maintainers point to their newer Pragmatic drag and drop), which makes dnd-kit a common choice for new React boards.

The data model

create table public.tasks (
  id              uuid primary key default gen_random_uuid(),
  organization_id uuid not null references public.organizations(id) on delete cascade,
  title           text not null,
  status          text not null,               -- the column: todo, doing, review, done
  position        double precision not null default 0,
  due_at          timestamptz,
  created_at      timestamptz not null default now()
);
create index on public.tasks (organization_id, status, position);

The composite index matches exactly how the board reads: one organization, grouped by column, ordered by position. Row-level security scopes it to the organization, as in the multi-tenant RLS guide.

Fractional positions: move one row, not many

The naive approach stores integer positions 1, 2, 3… and renumbers every card below the drop point on each move. That's many writes per drag and a race whenever two people move cards at once.

Fractional positions avoid it. When a card lands between two neighbours, its new position is the midpoint:

function midpoint(prev: number | null, next: number | null): number {
  if (prev === null && next === null) return 0;
  if (prev === null) return next! - 1;   // dropped at the top
  if (next === null) return prev + 1;   // dropped at the bottom
  return (prev + next) / 2;
}

One update per move. The catch: a double has 52 bits of mantissa, so repeatedly dropping cards into the same gap halves it each time, and after roughly fifty such drops in one spot the midpoint stops being distinct. In real use that's rare; the fix when it happens is a rebalance — renumber one column to evenly spaced values in a single transaction. Lexicographic string keys ("fractional indexing") are an alternative that never runs out, at the cost of slightly more code.

The board component

const sensors = useSensors(
  useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
  useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
 
return (
  <DndContext sensors={sensors} collisionDetection={closestCorners}
              onDragStart={onDragStart} onDragOver={onDragOver} onDragEnd={onDragEnd}>
    {COLUMNS.map((col) => (
      <Column key={col} id={col}>
        <SortableContext items={byColumn[col].map((t) => t.id)} strategy={verticalListSortingStrategy}>
          {byColumn[col].map((t) => <TaskCard key={t.id} task={t} />)}
        </SortableContext>
      </Column>
    ))}
    <DragOverlay>{active ? <TaskCard task={active} dragging /> : null}</DragOverlay>
  </DndContext>
);

Choices that matter:

  • activationConstraint: { distance: 6 } — a drag starts only after the pointer moves 6 pixels, so clicking a card to open it doesn't start a drag.
  • KeyboardSensor with sortableKeyboardCoordinates — keyboard users can pick up a card with Space, move it with the arrow keys and drop it with Space again.
  • closestCorners collision detection works better than closestCenter for stacked columns of different heights.
  • DragOverlay renders the dragged card in a portal above everything, so it isn't clipped by a scrolling column and doesn't reflow the list while moving.

Handling the drop

function onDragEnd({ active, over }: DragEndEvent) {
  if (!over) return;
  const to = columnOf(over.id);                       // a column id or a card's column
  let updated = moveInArray(tasks, active.id, over.id, to);
  const ordered = updated.filter((t) => t.status === to);
  const i = ordered.findIndex((t) => t.id === active.id);
  const newPos = midpoint(ordered[i - 1]?.position ?? null, ordered[i + 1]?.position ?? null);
  updated = updated.map((t) => (t.id === active.id ? { ...t, status: to, position: newPos } : t));
  setTasks(updated);                                   // optimistic
 
  void moveTask(active.id, to, newPos).then((res) => {
    if (!res.ok) { toast.error(res.error); router.refresh(); }   // roll back to server truth
  });
}

The server action validates the status against the allowed columns, then updates status and position through the user's own Supabase client — so row-level security decides whether this person may move this task at all.

Optimistic updates and server truth

Dragging must feel instant, so the UI moves the card before the server confirms. If the server refuses (no permission, the task was deleted, a network error), the board must converge back to the truth. The simplest reliable rollback is to refresh from the server rather than trying to undo locally.

One subtle bug to avoid: if a refresh arrives while a move is in flight, the stale server data can snap the card back for a moment. LetRelay keeps local board state and syncs refreshed server props into it during render, only when no optimistic change is pending.

Accessibility

dnd-kit provides the mechanics; you provide the words:

  • Give each card an accessible name (its title) and make it focusable.
  • Customize the screen-reader announcements ("Picked up Renew SSL certificate. Moved to Doing, position 2 of 5.").
  • Offer a non-drag alternative: a "Move to…" menu on each card. Some people can't drag at all, and the WAI-ARIA Authoring Practices recommend keyboard-operable alternatives for pointer interactions.
  • Keep a visible focus style on cards.

Columns, WIP limits and what a card should show

The mechanics are the easy part; a board people actually use needs a few product decisions.

Columns. Four is usually right for a team board: To do, Doing, Review, Done. Every extra column is a status someone must remember to update. Store the allowed columns in one place (a constant or a table) and validate moves against it on the server — the client's list of columns is a suggestion.

Work-in-progress limits. Kanban's core idea, described in the Kanban Guide, is limiting work in progress so work flows rather than piles up. Show a count on each column header ("Doing · 7 / 5") and colour it when over the limit. Enforcing the limit hard (refusing the drop) tends to frustrate people; making it visible changes behaviour on its own.

The card. Title, assignee avatars, due date and a checklist count ("3/5") cover most needs. Show due dates in the viewer's timezone, and compute "overdue" against the end of the due day, not midnight UTC. A task due Friday shouldn't appear overdue on Friday morning in California, or read as due Thursday. We fixed exactly that bug in LetRelay's board.

Done column hygiene. Hide cards completed more than a couple of weeks ago, with a "Show older" link. A done column of 800 cards slows rendering and buries recent wins.

Tasks vs requests. A task board holds work the team decides to do; a request queue holds what other people ask for, with deadlines set by request type. Keep them separate and link between them when a request turns into planned work. Prioritizing across both is covered in managing request priorities without chaos.

Realtime and conflicts

When several people use the board, show their moves live. Subscribe to changes on tasks for the organization and merge them into local state. Conflicts are rare with fractional positions — two people dropping into the same gap get two different midpoints — and when both edit the same card, the last write wins. For a task board that's acceptable; for anything financial it wouldn't be.

Performance

  • Memoize card components; a drag re-renders often.
  • For long columns (hundreds of cards), virtualize the list or paginate "done" — nobody needs 2,000 completed cards on screen.
  • Don't write on every onDragOver; write once on onDragEnd.

FAQ

Is dnd-kit better than react-beautiful-dnd?

For new projects, yes: dnd-kit supports keyboard and touch through sensors and handles more layouts, while react-beautiful-dnd was archived in August 2025 and is deprecated on npm.

How should I store card order in the database?

Use fractional positions (a float midpoint between neighbours, or lexicographic keys) so each move updates one row. Rebalance a column if midpoints get too close.

How do I make drag and drop accessible?

Enable the keyboard sensor, write clear screen-reader announcements, and provide a non-drag "Move to…" alternative.

Should card moves be optimistic?

Yes, for responsiveness — but reconcile with the server's answer and refresh on failure so the board never shows a move that didn't happen.

Sources

AA
Aayush Adhikari

Building Relay — the internal request desk with AI triage and SLA tracking.

Run your internal requests on LetRelay

AI triage, SLA-tracked queues, and bottleneck analytics — the help desk your team actually likes. Free to start.

Try LetRelay free No credit card required
Ad spaceYour Google AdSense unit shows here once approved.

Keep reading