How to Build a Kanban Board in Vanilla JavaScript

A step-by-step guide to building a drag-and-drop task board from scratch — no frameworks, no libraries, no build tools.

~8 min read • October 2026

javascript webdev tutorial frontend

In This Tutorial

  1. The Problem with Over-Engineering
  2. Step 1: HTML Structure
  3. Step 2: CSS Columns Layout
  4. Step 3: Core JavaScript Logic
  5. Step 4: Drag and Drop
  6. Step 5: Local Storage Persistence
  7. The Complete Solution
  8. Try It Live

The Problem with Over-Engineering

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.

Step 1: The HTML Structure

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:

Step 2: CSS Columns Layout

We'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.

Step 3: Core JavaScript Logic

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.

Step 4: Drag and Drop

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:

  1. dragstart — capture which card is being dragged
  2. dragover — allow the drop (required, otherwise drop is blocked)
  3. dragleave — clean up visual indicators
  4. drop — move the card in state, re-render, save
  5. dragend — clean up state variables

Step 5: Local Storage Persistence

A 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.

The Complete Solution

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:

Try It Live

The fully working Kanban Board is live and free to use:

Open Kanban Board → View This Tutorial

All 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.

What We Learned

Building a Kanban board from scratch taught us several important concepts: