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); // ... }); 

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"; }} > 

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" : ""} > 

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> 

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"> 

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.