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.
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
- Live demo: crm.bontemplates.com. Drag a few deals around.
- The template: pipeline, contacts, contact timeline, tasks, settings and auth pages, light and dark mode: crm.bontemplates.com/product
- Free starter in the same design system: Pulse Lite on GitHub
Questions about the board? Ask in the comments.

Top comments (1)
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:Small one too:
dragleavealso fires when the pointer moves from the column onto one of its own cards, so the highlight blinks off and back on. Checkingif (!e.currentTarget.contains(e.relatedTarget as Node)) setOver(null)inonDragLeavekeeps it steady.The
sr-onlyoverflow bug is a good catch, that one would have taken me a while to find.