DEV Community

Cover image for Build a Kanban Board with HTML, CSS & JavaScript
Artclick
Artclick

Posted on

Build a Kanban Board with HTML, CSS & JavaScript

Kanban boards are one of those UI patterns that look simple until you actually try to build one — three columns, draggable cards, done, right? What actually makes one feel good to use is a handful of details most tutorials skip: cards reordering live as you drag instead of just snapping at the end, a way to move cards without a mouse at all, and a board that remembers itself on refresh.

Here's the full build — vanilla HTML, CSS, and JavaScript, no libraries, no framework.


Preview of finished Kanban Board

What we're building

Three columns, cards that move between them by drag-and-drop or by button click, a form to add new cards per column, delete buttons, and the whole board state saved to localStorage so a refresh doesn't wipe the day's work. The interesting engineering is almost entirely in how the drag-and-drop is wired — that's where most of this tutorial lives.

Step 1: The HTML structure

Three columns, each with a header (name + card count), a list that will hold the cards, and a small form for adding new ones:

<div class="board" id="board">
  <section class="column" data-status="todo">
    <header class="column__header">
      <h2>To do</h2>
      <span class="column__count" data-count="todo">0</span>
    </header>
    <ul class="column__list" data-list="todo" aria-live="polite"></ul>
    <form class="column__add-form" data-add-form="todo">
      <input type="text" placeholder="Add a card…" aria-label="New card for To do" required />
      <button type="submit">Add</button>
    </form>
  </section>

  <section class="column" data-status="in-progress">
    <header class="column__header">
      <h2>In progress</h2>
      <span class="column__count" data-count="in-progress">0</span>
    </header>
    <ul class="column__list" data-list="in-progress" aria-live="polite"></ul>
    <form class="column__add-form" data-add-form="in-progress">
      <input type="text" placeholder="Add a card…" aria-label="New card for In progress" required />
      <button type="submit">Add</button>
    </form>
  </section>

  <section class="column" data-status="done">
    <header class="column__header">
      <h2>Done</h2>
      <span class="column__count" data-count="done">0</span>
    </header>
    <ul class="column__list" data-list="done" aria-live="polite"></ul>
    <form class="column__add-form" data-add-form="done">
      <input type="text" placeholder="Add a card…" aria-label="New card for Done" required />
      <button type="submit">Add</button>
    </form>
  </section>
</div>
Enter fullscreen mode Exit fullscreen mode

The cards themselves aren't in the markup — they get rendered by JavaScript from a state array, which is what makes drag-and-drop, persistence, and re-ordering all possible without fighting the DOM directly. data-status, data-list, and data-add-form are the hooks the JavaScript uses to find the right column without relying on fragile CSS selectors or hardcoded indexes.

aria-live="polite" on each list means a screen reader announces when a card is added, removed, or moved into that column — without it, none of that is announced at all.

Step 2: Layout and card styling

* {
  box-sizing: border-box;
}

body {
  font-family: system-ui, sans-serif;
  background: #f4f5f7;
  margin: 0;
  padding: 2rem;
}

.board {
  display: grid;
  grid-template-columns: repeat(3, minmax(260px, 1fr));
  gap: 1rem;
  max-width: 960px;
  margin: 0 auto;
}

.column {
  background: #ebecf0;
  border-radius: 0.6rem;
  padding: 0.9rem;
  display: flex;
  flex-direction: column;
  min-height: 200px;
}

.column__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 0.75rem;
  padding: 0 0.25rem;
}

.column__header h2 {
  font-size: 0.95rem;
  margin: 0;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: #44546f;
}

.column__count {
  background: #dfe1e6;
  color: #44546f;
  border-radius: 999px;
  padding: 0.1rem 0.6rem;
  font-size: 0.75rem;
  font-weight: 600;
}

.column__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  min-height: 60px;
  flex: 1;
}

.card {
  background: #fff;
  border-radius: 0.6rem;
  padding: 0.7rem 0.8rem;
  box-shadow: 0 1px 2px rgba(9, 30, 66, 0.2);
  cursor: grab;
  border-left: 4px solid var(--accent, #dfe1e6);
}

.column[data-status="todo"] { --accent: #dfe1e6; }
.column[data-status="in-progress"] { --accent: #4b7bec; }
.column[data-status="done"] { --accent: #36b37e; }
Enter fullscreen mode Exit fullscreen mode

The --accent custom property is set once per column and inherited down to every card inside it — that's what gives each column's cards a matching color strip without needing a class per card.

Step 3: State and persistence

Everything the board renders comes from one array, and that array is the only thing that gets saved and restored:

const STORAGE_KEY = 'kanban-board-state';
const STATUSES = ['todo', 'in-progress', 'done'];

let state = loadState();

function loadState() {
  const saved = localStorage.getItem(STORAGE_KEY);
  if (saved) return JSON.parse(saved);
  return [
    { id: crypto.randomUUID(), text: 'Sketch the board layout', status: 'todo' },
    { id: crypto.randomUUID(), text: 'Wire up drag and drop', status: 'in-progress' },
    { id: crypto.randomUUID(), text: 'Plan the columns', status: 'done' },
  ];
}

function saveState() {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
Enter fullscreen mode Exit fullscreen mode

crypto.randomUUID() is a built-in browser API — no dependency needed to generate a unique ID per card. If there's nothing saved yet, a few starter cards give the board something to show on first load instead of three empty columns.

Step 4: Rendering cards from state

The render function is the single source of truth for what's on screen — every action in this tutorial ends by calling it, rather than manually patching the DOM:

function render() {
  STATUSES.forEach((status) => {
    const list = document.querySelector(`[data-list="${status}"]`);
    list.innerHTML = '';
    state
      .filter((card) => card.status === status)
      .forEach((card) => list.appendChild(createCardElement(card)));
    document.querySelector(`[data-count="${status}"]`).textContent =
      state.filter((card) => card.status === status).length;
  });
}

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

  const currentIndex = STATUSES.indexOf(card.status);

  li.innerHTML = `
    <p class="card__text"></p>
    <div class="card__actions">
      <div class="card__move-buttons">
        <button type="button" data-move="-1" ${currentIndex === 0 ? 'disabled' : ''} aria-label="Move to ${STATUSES[currentIndex - 1]}">◀</button>
        <button type="button" data-move="1" ${currentIndex === STATUSES.length - 1 ? 'disabled' : ''} aria-label="Move to ${STATUSES[currentIndex + 1]}">▶</button>
      </div>
      <button type="button" class="card__delete" aria-label="Delete card">✕</button>
    </div>
  `;
  li.querySelector('.card__text').textContent = card.text;

  return li;
}

render();
Enter fullscreen mode Exit fullscreen mode

One deliberate detail: the card's text is set with .textContent, not baked into the innerHTML template string. Card text comes from whatever a user typed into the add-card form — running it through innerHTML would let someone type actual HTML into a card and have it execute. textContent always treats it as plain text, no exceptions.

The move buttons (◀ ▶) are the keyboard- and screen-reader-accessible way to move a card between columns — native HTML drag-and-drop has no keyboard equivalent at all, so without these, keyboard-only and screen-reader users would have no way to move a card between columns whatsoever. They're disabled at whichever end of the board a card is already at, so there's no button that does nothing when pressed.

Step 5: Adding, deleting, and moving cards

Three small event handlers, using one delegated click listener on the board for delete and move so the buttons work correctly even on cards that don't exist yet at page load:

document.querySelectorAll('.column__add-form').forEach((form) => {
  form.addEventListener('submit', (e) => {
    e.preventDefault();
    const input = form.querySelector('input');
    const text = input.value.trim();
    if (!text) return;
    state.push({ id: crypto.randomUUID(), text, status: form.dataset.addForm });
    input.value = '';
    saveState();
    render();
  });
});

document.getElementById('board').addEventListener('click', (e) => {
  const cardEl = e.target.closest('.card');
  if (!cardEl) return;
  const card = state.find((c) => c.id === cardEl.dataset.id);

  if (e.target.matches('.card__delete')) {
    state = state.filter((c) => c.id !== card.id);
    saveState();
    render();
  }

  if (e.target.matches('[data-move]')) {
    const direction = Number(e.target.dataset.move);
    const currentIndex = STATUSES.indexOf(card.status);
    card.status = STATUSES[currentIndex + direction];
    saveState();
    render();
  }
});
Enter fullscreen mode Exit fullscreen mode

At this point the board is fully usable — you can add, delete, and move cards between columns with the arrow buttons, and it survives a refresh. Drag-and-drop is the remaining piece, and it's additive on top of everything above rather than a replacement for it.

Step 6: Making cards draggable

The native HTML Drag and Drop API needs three things: an element marked draggable (already set in Step 4), a dragstart handler that records what's being dragged, and a dragend handler that cleans up afterward:

document.addEventListener('dragstart', (e) => {
  const card = e.target.closest('.card');
  if (!card) return;
  card.classList.add('dragging');
  e.dataTransfer.setData('text/plain', card.dataset.id);
  e.dataTransfer.effectAllowed = 'move';
});

document.addEventListener('dragend', (e) => {
  const card = e.target.closest('.card');
  if (card) card.classList.remove('dragging');
  document.querySelectorAll('.column__list.drag-over').forEach((list) =>
    list.classList.remove('drag-over')
  );
});
Enter fullscreen mode Exit fullscreen mode
.card.dragging {
  opacity: 0.4;
}

.column__list.drag-over {
  background: #e2ecfb;
  outline: 2px dashed #4b7bec;
  outline-offset: 2px;
}
Enter fullscreen mode Exit fullscreen mode

dataTransfer.setData('text/plain', card.dataset.id) is what actually carries information from the drag's start to wherever it eventually gets dropped — without it, the drop handler has no way to know which card was being dragged.

Step 7: Reordering live while dragging

This is the part that separates "cards jump between columns" from "this feels like a real kanban board" — the card should visibly slot into place as you drag it over other cards, not just wait until you let go:

document.querySelectorAll('.column__list').forEach((list) => {
  list.addEventListener('dragover', (e) => {
    e.preventDefault();
    list.classList.add('drag-over');
    const dragging = document.querySelector('.dragging');
    if (!dragging) return;
    const afterElement = getDragAfterElement(list, e.clientY);
    if (afterElement == null) {
      list.appendChild(dragging);
    } else {
      list.insertBefore(dragging, afterElement);
    }
  });

  list.addEventListener('dragleave', (e) => {
    if (e.target === list) list.classList.remove('drag-over');
  });
});

function getDragAfterElement(container, y) {
  const cards = [...container.querySelectorAll('.card:not(.dragging)')];
  return cards.reduce(
    (closest, child) => {
      const box = child.getBoundingClientRect();
      const offset = y - box.top - box.height / 2;
      if (offset < 0 && offset > closest.offset) {
        return { offset, element: child };
      }
      return closest;
    },
    { offset: Number.NEGATIVE_INFINITY }
  ).element;
}
Enter fullscreen mode Exit fullscreen mode

Two things worth calling out. First, e.preventDefault() inside dragover is not optional — by default, browsers don't allow dropping anything anywhere, specifically to stop random page content from being accidentally droppable. Skipping that one line is the single most common reason a drag-and-drop implementation silently doesn't work at all.

Second, getDragAfterElement is doing real work: for every card in the column already, it checks whether the cursor's Y position is above or below that card's midpoint, and finds the closest card the cursor is currently above. That becomes the "insert before this one" target — and if the cursor is below every card, it appends to the end instead. The actual card element gets physically moved in the DOM on every dragover firing, which is what produces the live reordering.

Step 8: Finishing the drop

By the time a drop fires, the card's DOM position is already exactly where the user wants it — Step 7 handled that live. What's left is making the underlying state array agree with what's now on screen:

document.querySelectorAll('.column__list').forEach((list) => {
  list.addEventListener('drop', (e) => {
    e.preventDefault();
    list.classList.remove('drag-over');
    syncStateFromDOM();
    render();
  });
});

function syncStateFromDOM() {
  const newState = [];
  document.querySelectorAll('.column__list').forEach((list) => {
    const status = list.dataset.list;
    list.querySelectorAll('.card').forEach((cardEl) => {
      const card = state.find((c) => c.id === cardEl.dataset.id);
      if (card) {
        card.status = status;
        newState.push(card);
      }
    });
  });
  state = newState;
  saveState();
}
Enter fullscreen mode Exit fullscreen mode

syncStateFromDOM walks every column in DOM order and rebuilds the state array to match — reading the DOM as the source of truth here, rather than trying to calculate the new position mathematically, is what keeps this reliable. The DOM already reflects exactly where the user dropped the card; recomputing that from scratch in JavaScript would just be a second, more error-prone way of re-deriving something that already exists on screen. Calling render() right after looks redundant since the DOM already matches, but it's what refreshes the column counts and rebuilds each card's move-button disabled states for its new column.

Step 9: An empty-column state

One small polish pass — a column with nothing in it shouldn't just look blank:

.column__list:empty::after {
  content: "No cards yet";
  display: block;
  text-align: center;
  color: #8993a4;
  font-size: 0.85rem;
  padding: 1rem 0;
  border: 2px dashed #c1c7d0;
  border-radius: 0.6rem;
}
Enter fullscreen mode Exit fullscreen mode

The :empty pseudo-class only matches when a list has zero child elements at all — no JavaScript needed to detect and toggle an empty state, the browser already knows.

A few things worth noting

Native HTML drag-and-drop doesn't support touch at all — it's a mouse-and-pointer API, so on a phone or tablet, dragging simply won't work, only the move buttons will. That's exactly why Step 5's move buttons aren't just an accessibility nicety; they're the only way this board works on mobile as written. A production version would either add touch event handling manually or reach for a library like SortableJS that abstracts over both.

Ideas to take it further

  • Labels or tags per card, with a small color swatch — reuses the same --accent custom-property trick from Step 2.
  • Due dates, shown on the card and highlighted when overdue with a :has() selector checking a data attribute.
  • Multiple boards, by namespacing the localStorage key per board ID instead of one fixed key.
  • Undo for deletes — keep the last deleted card in memory for a few seconds with a "card deleted, undo?" toast before it's gone for good.

Wrapping up

The genuinely interesting part of this build is Steps 7 and 8 working together: reorder the actual DOM live while dragging so it always feels responsive, then treat that final DOM order as the source of truth on drop instead of trying to calculate the new position mathematically. Once state and DOM agree, everything else — counts, persistence, the move buttons' disabled states — is just a normal re-render away.


At ArtClick, we build fast, scalable WordPress websites, company websites and custom web systems that balance design, performance and long-term maintainability. Whether you're starting from scratch or improving an existing platform, we'd love to help.

https://artclickdev.com/

Top comments (0)