If you've ever wanted to understand how drag-and-drop actually works under the hood — no libraries, no frameworks, just the browser — building a Kanban board is a great way to find out. Here's how I built one using plain HTML, CSS, and JavaScript.
The Idea
A Kanban board is one of those projects that looks simple on the surface but forces you to actually understand the DOM: creating elements, listening for events, and moving nodes around dynamically. I wanted three columns — To Do, In Progress, and Done — with cards you can drag between them.
Setting Up the Structure
The HTML is just a .board container holding three .list columns, each with a heading and a few .card elements marked draggable="true":
<div class="list" id="list1">
<h2>To do</h2>
<div class="card" draggable="true" id="card1">Wash dishes</div>
<div class="card" draggable="true" id="card2">Buy groceries</div>
</div>
That draggable="true" attribute is doing a lot of quiet work — it's what unlocks the HTML5 Drag and Drop API on that element.
Making It Draggable
The core logic comes down to a handful of events split between the cards and the lists.
On each card:
card.addEventListener("dragstart", dragStart);
card.addEventListener("dragend", dragEnd);
function dragStart(e) {
e.dataTransfer.setData("text/plain", this.id);
}
When a drag starts, I stash the card's id in the dataTransfer object. That's the only "state" I need to track — everything else can be looked up from the DOM.
On each list:
list.addEventListener("dragover", dragOver);
list.addEventListener("dragenter", dragEnter);
list.addEventListener("dragleave", dragLeave);
list.addEventListener("drop", dragDrop);
function dragOver(e) {
e.preventDefault(); // required, or drop never fires
}
function dragDrop(e) {
const id = e.dataTransfer.getData("text/plain");
const card = document.getElementById(id);
this.appendChild(card);
this.classList.remove("over");
}
The one gotcha that trips a lot of people up: dragover must call preventDefault(), or the drop event will never fire. Browsers block dropping by default, and that call is what tells the browser "yes, this element accepts drops."
Once the card is dropped, appendChild() handles the rest — since a card is a single DOM node, moving it to a new parent just relocates it, no cloning or re-rendering needed.
A Bit of Polish
I added a .over class that toggles on dragenter/dragleave to give the target list a subtle highlight while you're dragging over it — small detail, but it makes the board feel responsive instead of static.
.list.over {
background-color: #d1d3d3;
}
What I'd Add Next
This is intentionally a minimal version, but a few natural next steps:
-
Persistence — save board state to
localStorageso cards don't reset on refresh - Add/edit/delete cards — right now the cards are hardcoded in the HTML
- Touch support — the native Drag and Drop API doesn't work great on mobile, so a touch-based fallback would help
Wrap-up
No libraries, no build tools — just the Drag and Drop API and a bit of CSS. It's a solid project if you want a deeper feel for how the DOM handles events and element movement, and it's satisfying to drag a card across the screen and watch it just... work.
Code's on GitHub if you want to poke around or build on it: github.com/Praise-04-dev
Would love feedback — especially if you've built something similar and handled persistence or touch support differently!
Top comments (0)