A step-by-step guide to building a drag-and-drop task board from scratch — no frameworks, no libraries, no build tools.
Every developer has built a To-Do app. But a real Kanban board — with columns, drag-and-drop, and persistent state — is a surprisingly rich exercise in vanilla JavaScript. It teaches you about DOM manipulation, event handling, data structures, and state management without relying on React, Vue, or any framework.
In this tutorial, we'll build a fully functional Kanban board from scratch. No build tools, no package managers, no npm. Just one HTML file that works when you open it in a browser.
This tutorial assumes basic familiarity with HTML, CSS, and JavaScript. If you can read document.querySelector and understand what addEventListener does, you're ready.
A Kanban board has three columns (typically "To Do", "In Progress", "Done"). Each column holds a list of task cards. Here's the skeleton:
HTML<div class="board">
<div class="column" data-status="todo">
<h2>📋 To Do</h2>
<div class="card-list">
<div class="card" draggable="true" data-id="1">
<p>Research project requirements</p>
</div>
<div class="card" draggable="true" data-id="2">
<p>Set up development environment</p>
</div>
</div>
</div>
<div class="column" data-status="progress">
<h2>🔄 In Progress</h2>
<div class="card-list"></div>
</div>
<div class="column" data-status="done">
<h2>✅ Done</h2>
<div class="card-list"></div>
</div>
</div>
Key points:
.column has a data-status attribute for identifying the column.card has draggable="true" and a unique data-id.card-list — this is crucial for drop targetsWe'll use CSS Grid for the board layout and Flexbox for the columns. This gives us a responsive layout that works on any screen size.
CSS.board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
padding: 1.5rem;
min-height: 80vh;
}
.column {
background: #1e293b;
border-radius: 12px;
padding: 1rem;
display: flex;
flex-direction: column;
min-height: 200px;
}
.column h2 {
font-size: 1rem;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid #334155;
color: #e2e8f0;
}
.card-list {
flex: 1;
min-height: 50px;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.card {
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
padding: 0.75rem;
cursor: grab;
transition: opacity 0.2s, transform 0.2s;
}
.card:active {
cursor: grabbing;
}
.card.dragging {
opacity: 0.5;
transform: scale(0.95);
border-color: #3b82f6;
}
The min-height: 50px on .card-list is critical — without it, empty columns can't receive drops. The card's transition properties create the visual feedback when dragging.
Now for the state management. We need a data structure that represents our board, and functions to manipulate it.
JavaScript// The board state — a simple object with arrays
let board = {
todo: [],
progress: [],
done: []
};
// Initialize with sample data
function initBoard() {
board.todo = [
{ id: 1, text: "Research project requirements" },
{ id: 2, text: "Set up development environment" }
];
board.progress = [
{ id: 3, text: "Design database schema" }
];
board.done = [
{ id: 4, text: "Create project repository" }
];
}
// Render the entire board from state
function renderBoard() {
document.querySelectorAll('.card-list').forEach(list => {
list.innerHTML = '';
});
Object.entries(board).forEach(([status, cards]) => {
const list = document.querySelector(`.column[data-status="${status}"] .card-list`);
cards.forEach(card => {
const el = createCardElement(card);
list.appendChild(el);
});
});
}
// Create a DOM element from a card object
function createCardElement(card) {
const div = document.createElement('div');
div.className = 'card';
div.draggable = true;
div.dataset.id = card.id;
div.innerHTML = `${escapeHtml(card.text)}
`;
// Add drag event listeners
div.addEventListener('dragstart', handleDragStart);
div.addEventListener('dragend', handleDragEnd);
return div;
}
// Simple HTML escape to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
Key insight: The render function is called after every state change. This "render on change" pattern is how frameworks like React work internally — they just automate it for you. Here, we do it manually, which makes the concept crystal clear.
The HTML5 Drag and Drop API is notoriously verbose, but it's straightforward once you understand the events. Here's the complete handler:
JavaScriptlet draggedCard = null;
let draggedFromColumn = null;
function handleDragStart(e) {
draggedCard = this;
draggedFromColumn = this.closest('.column');
this.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', this.dataset.id);
}
function handleDragEnd(e) {
this.classList.remove('dragging');
draggedCard = null;
draggedFromColumn = null;
// Remove all drop indicators
document.querySelectorAll('.card-list').forEach(list => {
list.style.borderTop = 'none';
});
}
// Set up drop zones on each card-list
document.querySelectorAll('.card-list').forEach(list => {
list.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
list.style.borderTop = '2px solid #3b82f6';
});
list.addEventListener('dragleave', (e) => {
list.style.borderTop = 'none';
});
list.addEventListener('drop', (e) => {
e.preventDefault();
list.style.borderTop = 'none';
const cardId = parseInt(e.dataTransfer.getData('text/plain'));
const targetStatus = list.closest('.column').dataset.status;
// Find the card in the current column
const currentColumn = draggedFromColumn.dataset.status;
const cardIndex = board[currentColumn].findIndex(c => c.id === cardId);
if (cardIndex === -1) return;
// Move the card to the new column
const [card] = board[currentColumn].splice(cardIndex, 1);
board[targetStatus].push(card);
// Re-render the board
renderBoard();
// Persist to localStorage
saveBoard();
});
});
The drag-and-drop flow is:
dragstart — capture which card is being draggeddragover — allow the drop (required, otherwise drop is blocked)dragleave — clean up visual indicatorsdrop — move the card in state, re-render, savedragend — clean up state variablesA Kanban board is useless if it forgets everything when you refresh. LocalStorage gives us free persistence with zero setup:
JavaScriptconst STORAGE_KEY = 'kanban-board-state';
function saveBoard() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(board));
}
function loadBoard() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
board = JSON.parse(saved);
} catch (e) {
console.error('Failed to load board state', e);
}
}
}
// Load on page load, save on every change
loadBoard();
renderBoard();
LocalStorage has a ~5MB limit and is synchronous. For a Kanban board, this is perfectly fine. For larger applications, consider IndexedDB or a proper backend.
Here's everything together — copy this into an HTML file and open it in your browser:
index.html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kanban Board</title>
<style>
/* Paste the CSS from Step 2 here */
</style>
</head>
<body>
<div class="board">
<!-- Paste the HTML from Step 1 here -->
</div>
<script>
// Paste the JavaScript from Steps 3-5 here
</script>
</body>
</html>
The full, working implementation is available as a single HTML file — no build step required. It includes:
The fully working Kanban Board is live and free to use:
Open Kanban Board → View This TutorialAll tools on k1r4.space are free, open-source, and require no signup, tracking, or ads. They're built with vanilla HTML, CSS, and JavaScript — no frameworks, no build tools.
Building a Kanban board from scratch taught us several important concepts: