DEV Community

Cover image for A kanban board in React with native drag and drop (and a fallback that works on phones)
Bon
Bon

Posted on Fully Autonomous

A kanban board in React with native drag and drop (and a fallback that works on phones)

I recently built Tandem, a CRM dashboard template with Next.js 16 and Tailwind CSS 4. The centerpiece is a kanban pipeline: deals move from Lead to Won. I wanted it without a drag-and-drop library, and I wanted it to work for people who can't drag. Here is the whole approach.

Tandem pipeline board

1. State: one array, stage is just a field

Columns don't own their cards. There is one list of deals, and a column is a filter:

const [deals, setDeals] = useState(initialDeals);

function move(id: string, stage: StageId) {
  setDeals((list) => list.map((d) => (d.id === id ? { ...d, stage } : d)));
}

// in render
stages.map((s) => {
  const list = visible.filter((d) => d.stage === s.id);
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Moving a card is a single map. Totals per column, the weighted forecast and filters all fall out of the same array for free.

2. Native HTML drag and drop

The browser already has drag and drop. You need three handlers.

On the card:

<article
  draggable
  onDragStart={(e) => {
    e.dataTransfer.setData("text/plain", deal.id);
    e.dataTransfer.effectAllowed = "move";
  }}
>
Enter fullscreen mode Exit fullscreen mode

On the column:

<section
  onDragOver={(e) => {
    e.preventDefault(); // without this, drop never fires
    setOver(stage.id);
  }}
  onDragLeave={() => setOver((o) => (o === stage.id ? null : o))}
  onDrop={(e) => {
    e.preventDefault();
    move(e.dataTransfer.getData("text/plain"), stage.id);
    setOver(null);
  }}
  className={over === stage.id ? "border-brand bg-brand-soft/50" : ""}
>
Enter fullscreen mode Exit fullscreen mode

The over state only exists to highlight the column you're about to drop into. That small detail makes the board feel much more solid.

3. The part most boards skip: a non-drag way to move cards

Native drag and drop doesn't work with touch on most mobile browsers, and it never works with a keyboard. Instead of fighting that, every card has a plain <select>:

<label className="sr-only" htmlFor={`move-${deal.id}`}>
  Move {deal.company} to stage
</label>
<select id={`move-${deal.id}`} value={deal.stage} onChange={(e) => move(deal.id, e.target.value as StageId)}>
  {stages.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
Enter fullscreen mode Exit fullscreen mode

It calls the same move function. On a phone, it opens the native picker, which is honestly nicer than dragging a card across a horizontally scrolling board.

4. A bug I didn't expect: sr-only broke the mobile layout

On a 390px screen the whole page suddenly scrolled sideways, even though the board sat in an overflow-x-auto container.

The cause: Tailwind's sr-only is position: absolute. With no positioned ancestor, those hidden labels were positioned relative to the page, not the scroll container, so they escaped the overflow clip and stretched the document to 1500px.

The fix was one word, relative on the scroll container:

<div className="relative -mx-4 overflow-x-auto px-4">
Enter fullscreen mode Exit fullscreen mode

If a page scrolls horizontally on mobile and you can't see why, look for absolutely positioned elements without a relative parent.

I built Tandem with AI assistance, then reviewed and tested everything myself.

Try it

Questions about the board? Ask in the comments.

Top comments (1)

Collapse
 
sunnatulla_dev profile image
Sunnatulla •

One thing to try on Windows with the board wider than the screen: start dragging a card and spin the mouse wheel. In Chrome the wheel does nothing while a native drag is active, so you can't reach a column that's scrolled out of view. I had to fix exactly this on a drag and drop UI once, and what worked was scrolling the container yourself from dragover, which keeps firing for as long as the drag is over the element:

const EDGE = 60;

function autoScroll(e: React.DragEvent<HTMLDivElement>) {
  const el = e.currentTarget;
  const { left, right } = el.getBoundingClientRect();
  if (e.clientX < left + EDGE) el.scrollLeft -= 20;
  else if (e.clientX > right - EDGE) el.scrollLeft += 20;
}

<div className="relative -mx-4 overflow-x-auto px-4" onDragOver={autoScroll}>
Enter fullscreen mode Exit fullscreen mode

Small one too: dragleave also fires when the pointer moves from the column onto one of its own cards, so the highlight blinks off and back on. Checking if (!e.currentTarget.contains(e.relatedTarget as Node)) setOver(null) in onDragLeave keeps it steady.

The sr-only overflow bug is a good catch, that one would have taken me a while to find.