Create a Drag and Drop Kanban Board with Vanilla JavaScript

Coding ProjectsCreate a Drag and Drop Kanban Board with Vanilla JavaScript

Think you need React or a library to build a slick Kanban board?
You don’t. We’ll build one from scratch using only HTML, CSS, and vanilla JavaScript, no build tools or packages.
Follow this step-by-step guide and you’ll wire up the HTML structure, style the layout, create a simple data model, and implement native drag-and-drop events so cards move between columns.
By the end you’ll have a working board in your browser and a clear pattern you can reuse in your own projects.

Setting Up Your Project Files

ITK0773-W7GMvkyTWwbILw

Create a new folder on your computer and name it kanban-board. Inside, you’ll need three files: index.html, styles.css, and script.js. Open your code editor and create these now.

Your folder structure should look like this:

kanban-board/
  ├── index.html
  ├── styles.css
  └── script.js

That’s it. Everything we build lives in these three files. No frameworks, no build tools, no npm packages. Just plain HTML, CSS, and JavaScript working together.

Building the HTML Structure

pPw5RBlIXjejdcryjowVYQ

Open index.html and add this skeleton:

<!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>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <header>
    <h1>My Kanban Board</h1>
  </header>

  <main class="board-container">
    <!-- Columns go here -->
  </main>

  <script src="script.js"></script>
</body>
</html>

Now add the column structure inside <main>. Each column needs a header, a drop zone for cards, and a button to add tasks:

<main class="board-container">
  <div class="column" data-status="todo">
    <div class="column-header">
      <h2>To Do</h2>
    </div>
    <div class="card-list" id="todo-list"></div>
    <button class="add-task-btn" data-column="todo">+ Add Task</button>
  </div>

  <div class="column" data-status="in-progress">
    <div class="column-header">
      <h2>In Progress</h2>
    </div>
    <div class="card-list" id="in-progress-list"></div>
    <button class="add-task-btn" data-column="in-progress">+ Add Task</button>
  </div>

  <div class="column" data-status="done">
    <div class="column-header">
      <h2>Done</h2>
    </div>
    <div class="card-list" id="done-list"></div>
    <button class="add-task-btn" data-column="done">+ Add Task</button>
  </div>
</main>

See the data-status attribute on each column? We’ll use that to identify where cards belong. The card-list divs are empty because JavaScript fills them with task cards.

Each task card looks like this when JavaScript creates it:

<div class="card" draggable="true" data-id="1">
  <div class="card-content">
    <h3 class="card-title">Write documentation</h3>
    <p class="card-description">Create user guide for new feature</p>
  </div>
  <div class="card-controls">
    <button class="edit-btn">Edit</button>
    <button class="delete-btn">Delete</button>
  </div>
</div>

The draggable="true" attribute makes the magic happen. Without it, the browser won’t let you drag the card.

Styling the Board Layout

AaKhacb1WJ2MOOgjK_xs9Q

Open styles.css. Start with a basic reset and body styling:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: #1a1a2e;
  color: #eee;
  padding: 20px;
}

header {
  text-align: center;
  margin-bottom: 30px;
}

header h1 {
  font-size: 2rem;
  color: #fff;
}

Now layout the board using Flexbox:

.board-container {
  display: flex;
  gap: 20px;
  max-width: 1400px;
  margin: 0 auto;
  overflow-x: auto;
}

.column {
  flex: 1;
  min-width: 300px;
  background: rgba(255, 255, 255, 0.05);
  border-radius: 12px;
  padding: 16px;
  display: flex;
  flex-direction: column;
}

.column-header h2 {
  font-size: 1.2rem;
  margin-bottom: 16px;
  color: #fff;
}

.card-list {
  flex: 1;
  min-height: 200px;
  padding: 8px;
  border-radius: 8px;
  transition: background 0.2s;
}

Style the task cards:

.card {
  background: #2d2d44;
  border-radius: 8px;
  padding: 16px;
  margin-bottom: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
  cursor: grab;
  transition: transform 0.2s, box-shadow 0.2s;
}

.card:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}

.card.dragging {
  opacity: 0.5;
  cursor: grabbing;
}

.card-title {
  font-size: 1rem;
  margin-bottom: 8px;
  color: #fff;
}

.card-description {
  font-size: 0.875rem;
  color: #aaa;
  margin-bottom: 12px;
}

.card-controls {
  display: flex;
  gap: 8px;
}

.card-controls button {
  padding: 6px 12px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.875rem;
  transition: background 0.2s;
}

.edit-btn {
  background: #3498db;
  color: #fff;
}

.edit-btn:hover {
  background: #2980b9;
}

.delete-btn {
  background: #e74c3c;
  color: #fff;
}

.delete-btn:hover {
  background: #c0392b;
}

Add a visual cue when dragging over a drop zone:

.card-list.drag-over {
  background: rgba(52, 152, 219, 0.1);
  border: 2px dashed #3498db;
}

.add-task-btn {
  width: 100%;
  padding: 12px;
  margin-top: 12px;
  background: rgba(255, 255, 255, 0.1);
  border: 2px dashed rgba(255, 255, 255, 0.3);
  border-radius: 8px;
  color: #fff;
  cursor: pointer;
  font-size: 0.95rem;
  transition: background 0.2s, border-color 0.2s;
}

.add-task-btn:hover {
  background: rgba(255, 255, 255, 0.15);
  border-color: rgba(255, 255, 255, 0.5);
}

Make the board responsive:

@media (max-width: 768px) {
  .board-container {
    flex-direction: column;
  }

  .column {
    min-width: 100%;
  }
}

On mobile, columns stack. On desktop, they sit side by side.

Designing Your Data Model

XWBHRh-aUN28h1OcQodQDw

Before writing drag and drop code, you need a data structure to hold your board state. Open script.js and create this:

let boardData = {
  columns: {
    'todo': [],
    'in-progress': [],
    'done': []
  },
  nextId: 1
};

Each column is an array of task objects. A task looks like this:

{
  id: 1,
  title: "Write documentation",
  description: "Create user guide for new feature",
  status: "todo"
}

The nextId counter gives every task a unique ID. When you create a task, increment it:

function createTask(title, description, status) {
  const task = {
    id: boardData.nextId++,
    title: title,
    description: description,
    status: status
  };

  boardData.columns[status].push(task);
  return task;
}

Write a function to render the entire board from data:

function renderBoard() {
  // Clear all columns
  document.querySelectorAll('.card-list').forEach(list => {
    list.innerHTML = '';
  });

  // Render each column
  for (let status in boardData.columns) {
    const tasks = boardData.columns[status];
    const listElement = document.getElementById(`${status}-list`);

    tasks.forEach(task => {
      const card = createCardElement(task);
      listElement.appendChild(card);
    });
  }
}

The createCardElement function builds the HTML for one card:

function createCardElement(task) {
  const card = document.createElement('div');
  card.className = 'card';
  card.draggable = true;
  card.dataset.id = task.id;

  card.innerHTML = `
    <div class="card-content">
      <h3 class="card-title">${task.title}</h3>
      <p class="card-description">${task.description}</p>
    </div>
    <div class="card-controls">
      <button class="edit-btn">Edit</button>
      <button class="delete-btn">Delete</button>
    </div>
  `;

  addDragListeners(card);
  addCardControls(card);

  return card;
}

When you call renderBoard(), every task in your data model appears on screen.

Implementing Drag and Drop Events

l-7D7F8DX9ylKKoZbCJXGg

The browser fires five key events during a drag operation. Here’s what each one does and when you need to handle it:

Event Fires On What You Do
dragstart The card being dragged Store the card’s ID and add a visual “dragging” class
dragend The card being dragged Remove the “dragging” class
dragover The drop zone (card list) Call preventDefault() to allow dropping
dragenter The drop zone Add a visual “drag-over” highlight
drop The drop zone Read the card ID, move the card, update your data

Add the drag listeners to each card:

function addDragListeners(card) {
  card.addEventListener('dragstart', handleDragStart);
  card.addEventListener('dragend', handleDragEnd);
}

function handleDragStart(e) {
  e.dataTransfer.effectAllowed = 'move';
  e.dataTransfer.setData('text/plain', e.target.dataset.id);
  e.target.classList.add('dragging');
}

function handleDragEnd(e) {
  e.target.classList.remove('dragging');
}

When you start dragging, setData stores the card’s ID in the browser’s transfer object. You’ll read that ID when the card drops.

Set up listeners on every drop zone:

document.querySelectorAll('.card-list').forEach(list => {
  list.addEventListener('dragover', handleDragOver);
  list.addEventListener('dragenter', handleDragEnter);
  list.addEventListener('dragleave', handleDragLeave);
  list.addEventListener('drop', handleDrop);
});

The dragover handler must call preventDefault() or the drop won’t work:

function handleDragOver(e) {
  e.preventDefault();
  e.dataTransfer.dropEffect = 'move';
}

Add visual feedback when hovering over a drop zone:

function handleDragEnter(e) {
  if (e.target.classList.contains('card-list')) {
    e.target.classList.add('drag-over');
  }
}

function handleDragLeave(e) {
  if (e.target.classList.contains('card-list')) {
    e.target.classList.remove('drag-over');
  }
}

Handle the drop:

function handleDrop(e) {
  e.preventDefault();
  e.target.classList.remove('drag-over');

  const cardId = parseInt(e.dataTransfer.getData('text/plain'));
  const newStatus = e.target.closest('.column').dataset.status;

  moveTask(cardId, newStatus);
  renderBoard();
}

The moveTask function updates your data model:

function moveTask(taskId, newStatus) {
  let task = null;

  // Find and remove the task from its current column
  for (let status in boardData.columns) {
    const index = boardData.columns[status].findIndex(t => t.id === taskId);
    if (index !== -1) {
      task = boardData.columns[status].splice(index, 1)[0];
      break;
    }
  }

  // Add it to the new column
  if (task) {
    task.status = newStatus;
    boardData.columns[newStatus].push(task);
  }
}

You can now drag cards between columns and the board updates correctly.

Adding Task CRUD Operations

XFriSiYWVFCr4VcGSC_Wxg

Create a function to add controls to each card:

function addCardControls(card) {
  const editBtn = card.querySelector('.edit-btn');
  const deleteBtn = card.querySelector('.delete-btn');

  editBtn.addEventListener('click', () => handleEdit(card));
  deleteBtn.addEventListener('click', () => handleDelete(card));
}

Handle the “Add Task” buttons:

document.querySelectorAll('.add-task-btn').forEach(btn => {
  btn.addEventListener('click', (e) => {
    const status = e.target.dataset.column;
    handleAddTask(status);
  });
});

function handleAddTask(status) {
  const title = prompt('Task title:');
  if (!title) return;

  const description = prompt('Task description:');

  createTask(title, description || '', status);
  renderBoard();
  saveToLocalStorage();
}

This uses simple browser prompts. You can swap these out for a modal form later.

Edit a task:

function handleEdit(card) {
  const taskId = parseInt(card.dataset.id);
  const task = findTaskById(taskId);

  if (!task) return;

  const newTitle = prompt('Edit title:', task.title);
  if (newTitle !== null) {
    task.title = newTitle;
  }

  const newDescription = prompt('Edit description:', task.description);
  if (newDescription !== null) {
    task.description = newDescription;
  }

  renderBoard();
  saveToLocalStorage();
}

function findTaskById(id) {
  for (let status in boardData.columns) {
    const task = boardData.columns[status].find(t => t.id === id);
    if (task) return task;
  }
  return null;
}

Delete a task:

function handleDelete(card) {
  const taskId = parseInt(card.dataset.id);
  const confirmed = confirm('Delete this task?');

  if (!confirmed) return;

  for (let status in boardData.columns) {
    const index = boardData.columns[status].findIndex(t => t.id === taskId);
    if (index !== -1) {
      boardData.columns[status].splice(index, 1);
      break;
    }
  }

  renderBoard();
  saveToLocalStorage();
}

You can now create, edit, and delete tasks through the UI.

Persisting Data with localStorage

UyIMpp2tX6-QX4XqFBmENw

Add save and load functions:

function saveToLocalStorage() {
  localStorage.setItem('kanbanBoard', JSON.stringify(boardData));
}

function loadFromLocalStorage() {
  const saved = localStorage.getItem('kanbanBoard');
  if (saved) {
    boardData = JSON.parse(saved);
  }
}

Call loadFromLocalStorage() when the page loads:

document.addEventListener('DOMContentLoaded', () => {
  loadFromLocalStorage();
  renderBoard();
});

Call saveToLocalStorage() after every change. You already added it to the add, edit, and delete handlers. Add it to the drop handler too:

function handleDrop(e) {
  e.preventDefault();
  e.target.classList.remove('drag-over');

  const cardId = parseInt(e.dataTransfer.getData('text/plain'));
  const newStatus = e.target.closest('.column').dataset.status;

  moveTask(cardId, newStatus);
  renderBoard();
  saveToLocalStorage(); // Add this line
}

Your board persists across browser sessions. Close the tab and reopen it. Your tasks are still there.

Export the board as JSON:

function exportBoard() {
  const dataStr = JSON.stringify(boardData, null, 2);
  const blob = new Blob([dataStr], { type: 'application/json' });
  const url = URL.createObjectURL(blob);

  const link = document.createElement('a');
  link.href = url;
  link.download = 'kanban-board.json';
  link.click();

  URL.revokeObjectURL(url);
}

Import from JSON:

function importBoard(file) {
  const reader = new FileReader();
  reader.onload = (e) => {
    try {
      boardData = JSON.parse(e.target.result);
      renderBoard();
      saveToLocalStorage();
    } catch (error) {
      alert('Invalid JSON file');
    }
  };
  reader.readAsText(file);
}

Add export/import buttons to your HTML:

<header>
  <h1>My Kanban Board</h1>
  <div class="header-controls">
    <button onclick="exportBoard()">Export</button>
    <input type="file" id="import-input" accept=".json" style="display: none;">
    <button onclick="document.getElementById('import-input').click()">Import</button>
  </div>
</header>

Wire up the import input:

document.getElementById('import-input').addEventListener('change', (e) => {
  const file = e.target.files[0];
  if (file) {
    importBoard(file);
  }
});

You can now back up your board and share it with others.

Adding Accessibility Features

At_WR24uXvioBxyB8rtn8A

Screen readers can’t “see” your drag and drop interactions. Add ARIA labels to help:

function createCardElement(task) {
  const card = document.createElement('div');
  card.className = 'card';
  card.draggable = true;
  card.dataset.id = task.id;
  card.setAttribute('role', 'button');
  card.setAttribute('aria-label', `Task: ${task.title}. Press Enter to move.`);
  card.tabIndex = 0;

  // ... rest of the code
}

Add keyboard controls so users can move tasks without a mouse:

card.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') {
    handleKeyboardMove(task);
  }
});

function handleKeyboardMove(task) {
  const statuses = ['todo', 'in-progress', 'done'];
  const currentIndex = statuses.indexOf(task.status);
  const nextIndex = (currentIndex + 1) % statuses.length;
  const newStatus = statuses[nextIndex];

  moveTask(task.id, newStatus);
  renderBoard();
  saveToLocalStorage();

  // Refocus the moved card
  setTimeout(() => {
    const movedCard = document.querySelector(`[data-id="${task.id}"]`);
    if (movedCard) movedCard.focus();
  }, 100);
}

Pressing Enter on a focused card moves it to the next column. Press again to move it further.

Label the drop zones:

<div class="card-list" id="todo-list" role="region" aria-label="To Do tasks"></div>

These small changes make your board usable for everyone.

Troubleshooting Common Issues

dOJDMlCCVFuZ7hQ2ZJ_gHQ

If cards won’t drag, check these three things:

  1. Make sure draggable="true" is set on the card element
  2. Verify you’re calling e.preventDefault() inside dragover
  3. Check that setData and getData use the same data type ('text/plain')

If the drop doesn’t work, log the event to see what’s happening:

function handleDrop(e) {
  console.log('Drop event:', e);
  console.log('Target:', e.target);
  console.log('Data:', e.dataTransfer.getData('text/plain'));

  // ... rest of the code
}

If cards appear in the wrong column after refresh, your status field might not match the column’s data-status. Double check the spelling:

// These must match exactly
<div class="column" data-status="in-progress">
// and
task.status = "in-progress"

Dragging feels jumpy or broken? You might have nested interactive elements. Buttons inside draggable cards can interfere. Stop propagation on button clicks:

editBtn.addEventListener('click', (e) => {
  e.stopPropagation();
  handleEdit(card);
});

If localStorage won’t save, check your data size. LocalStorage has a 5 to 10 MB limit per domain. For larger boards, switch to IndexedDB or a backend API.

Extending Your Kanban Board

PiBP4btFXGWu_7iJYZ_Mdg

Add priority labels. Extend your task model:

{
  id: 1,
  title: "Write documentation",
  description: "Create user guide",
  status: "todo",
  priority: "high" // new field
}

Show priority as a colored badge:

card.innerHTML = `
  <div class="card-content">
    <span class="priority-badge priority-${task.priority}">${task.priority}</span>
    <h3 class="card-title">${task.title}</h3>
    <p class="card-description">${task.description}</p>
  </div>
  <div class="card-controls">
    <button class="edit-btn">Edit</button>
    <button class="delete-btn">Delete</button>
  </div>
`;

Style the badges:

.priority-badge {
  display: inline-block;
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 0.75rem;
  text-transform: uppercase;
  margin-bottom: 8px;
}

.priority-high {
  background: #e74c3c;
  color: #fff;
}

.priority-medium {
  background: #f39c12;
  color: #fff;
}

.priority-low {
  background: #3498db;
  color: #fff;
}

Add due dates:

{
  id: 1,
  title: "Write documentation",
  description: "Create user guide",
  status: "todo",
  priority: "high",
  dueDate: "2025-02-01" // ISO date string
}

Display the due date and highlight overdue tasks:

function createCardElement(task) {
  const card = document.createElement('div');
  card.className = 'card';

  const isOverdue = task.dueDate && new Date(task.dueDate) < new Date();
  if (isOverdue) {
    card.classList.add('overdue');
  }

  card.innerHTML = `
    <div class="card-content">
      ${task.priority ? `<span class="priority-badge priority-${task.priority}">${task.priority}</span>` : ''}
      <h3 class="card-title">${task.title}</h3>
      <p class="card-description">${task.description}</p>
      ${task.dueDate ? `<p class="due-date">Due: ${formatDate(task.dueDate)}</p>` : ''}
    </div>
    <div class="card-controls">
      <button class="edit-btn">Edit</button>
      <button class="delete-btn">Delete</button>
    </div>
  `;

  // ... rest of code
}

function formatDate(isoDate) {
  const date = new Date(isoDate);
  return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}

Style overdue cards:

.card.overdue {
  border-left: 4px solid #e74c3c;
}

.due-date {
  font-size: 0.8rem;
  color: #aaa;
  margin-top: 8px;
}

.overdue .due-date {
  color: #e74c3c;
  font-weight: bold;
}

Add search and filter. Create an input field in your header:

<header>
  <h1>My Kanban Board</h1>
  <input type="text" id="search-input" placeholder="Search tasks...">
</header>

Filter tasks as you type:

document.getElementById('search-input').addEventListener('input', (e) => {
  const query = e.target.value.toLowerCase();

  document.querySelectorAll('.card').forEach(card => {
    const title = card.querySelector('.card-title').textContent.toLowerCase();
    const description = card.querySelector('.card-description').textContent.toLowerCase();

    if (title.includes(query) || description.includes(query)) {
      card.style.display = 'block';
    } else {
      card.style.display = 'none';
    }
  });
});

Support multiple boards. Update your data model:

let appData = {
  boards: [
    {
      id: 1,
      name: "Work Projects",
      columns: { 'todo': [], 'in-progress': [], 'done': [] },
      nextId: 1
    }
  ],
  activeBoard: 1
};

Add a board switcher in your UI:

<select id="board-selector">
  <option value="1">Work Projects</option>
  <option value="2">Personal Tasks</option>
</select>

Switch boards on selection:

document.getElementById('board-selector').addEventListener('change', (e) => {
  appData.activeBoard = parseInt(e.target.value);
  loadActiveBoard();
  renderBoard();
});

function loadActiveBoard() {
  boardData = appData.boards.find(b => b.id === appData.activeBoard);
}

Sync with a backend. Replace localStorage calls with API requests:

async function saveToServer() {
  await fetch('/api/boards', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(boardData)
  });
}

async function loadFromServer() {
  const response = await fetch('/api/boards');
  boardData = await response.json();
}

Call these instead of saveToLocalStorage and loadFromLocalStorage. You’ll need a backend API (Node.js, Python, PHP, whatever you prefer) to handle the requests.

Final Words

You built a live Kanban board: HTML structure, CSS columns, draggable cards, and the drag-and-drop event logic.

The post walked through the data model, event handlers, saving with localStorage, and small accessibility and debugging tips to keep things stable.

If you followed along, you now know how to create a drag and drop kanban board with vanilla javascript step by step. Tweak styles, add features, and keep shipping. That momentum matters.

FAQ

Q: How to create drag and drop in JavaScript?

A: Creating drag and drop in JavaScript uses draggable attributes and drag events (dragstart, dragover, drop). Add event listeners, use DataTransfer for payloads, preventDefault on dragover, and update the DOM on drop.

Q: What is drag and drop kanban?

A: Drag and drop Kanban is a visual workflow board where tasks (cards) move between columns representing stages; dragging updates status, shows flow, and helps limit work-in-progress for clearer team focus.

Q: How to set up a simple Kanban board?

A: To set up a simple Kanban board, create columns like To Do, Doing, Done; add movable cards; set a WIP limit; wire basic drag handlers or a tiny library; keep columns and rules minimal.

Q: How to create a vanilla JS project?

A: Creating a vanilla JS project involves making a folder, adding index.html and script.js, linking the script tag, opening the page in a browser or simple dev server, and optionally initializing Git.

Check out our other content

Check out other tags: