DEV Community

SEN LLC
SEN LLC

Posted on

A Local Kanban Board With HTML5 Drag and Drop and Immutable Board State

A Local Kanban Board With HTML5 Drag and Drop and Immutable Board State

The board state is a plain tree object: board → columns → cards. All 12 mutation functions return a new tree without touching the input. HTML5 drag-and-drop provides the interaction layer — no library needed, but the event model has a few gotchas worth knowing about.

Every team uses Kanban, but the cloud versions cost money and require accounts. A local board that lives entirely in your browser — stored in localStorage, exportable as JSON — is surprisingly serviceable for personal projects.

🔗 Live demo: https://sen.ltd/portfolio/kanban-local/
📦 GitHub: https://github.com/sen-ltd/kanban-local

Screenshot

Features:

  • Multiple boards with custom columns
  • HTML5 drag and drop (cards between columns, reorder within column, reorder columns)
  • localStorage persistence
  • JSON import / export
  • Search / filter across all cards
  • Color labels per card
  • Japanese / English UI
  • Zero dependencies, 46 tests

Immutable state

The whole app state is one object tree. Every operation returns a new tree:

export function addCard(board, columnId, card) {
  return {
    ...board,
    columns: board.columns.map(col =>
      col.id === columnId ? { ...col, cards: [...col.cards, card] } : col
    ),
  };
}

export function moveCard(board, fromColId, cardId, toColId, toIndex) {
  const fromCol = board.columns.find(c => c.id === fromColId);
  const card = fromCol.cards.find(c => c.id === cardId);
  return {
    ...board,
    columns: board.columns.map(col => {
      if (col.id === fromColId) {
        return { ...col, cards: col.cards.filter(c => c.id !== cardId) };
      }
      if (col.id === toColId) {
        const newCards = [...col.cards];
        newCards.splice(toIndex, 0, card);
        return { ...col, cards: newCards };
      }
      return col;
    }),
  };
}
Enter fullscreen mode Exit fullscreen mode

fromCol === toCol gets handled by a separate reorderCard path because both branches need to apply in sequence.

The same-column drop index adjustment

When dragging within a single column, there's an off-by-one to handle: if you remove the dragged card first, the destination index shifts. The UI layer handles this:

function handleDrop(fromColId, cardId, toColId, rawIndex) {
  let toIndex = rawIndex;
  if (fromColId === toColId) {
    const fromIndex = findCardIndex(fromColId, cardId);
    if (toIndex > fromIndex) toIndex -= 1;
  }
  state = moveCard(state, fromColId, cardId, toColId, toIndex);
}
Enter fullscreen mode Exit fullscreen mode

Without this, dragging a card from position 0 to position 2 would end up at position 1, because the removal of position 0 shifts everything down.

HTML5 drag events

The native drag API has a specific event sequence:

  1. dragstart on source → set dataTransfer, add a CSS class
  2. dragenter on target → first time cursor enters
  3. dragover on target → fires continuously while hovering. Must preventDefault() or drop won't work.
  4. dragleave on target → cursor leaves (fires on child element boundaries too — watch out)
  5. drop on target → set final state
  6. dragend on source → always fires, even on cancel. Use this for cleanup.
card.addEventListener('dragstart', (e) => {
  e.dataTransfer.setData('text/plain', cardId);
  card.classList.add('dragging');
});

column.addEventListener('dragover', (e) => {
  e.preventDefault(); // CRITICAL
  // Compute drop index based on cursor Y position
});

column.addEventListener('drop', (e) => {
  e.preventDefault();
  const cardId = e.dataTransfer.getData('text/plain');
  // apply move
});
Enter fullscreen mode Exit fullscreen mode

The dragleave firing on child boundaries is a common gotcha. If you want to remove the highlight when the cursor genuinely leaves the column, you need to track enter/leave counts or use e.relatedTarget checks.

localStorage persistence

Every state mutation triggers a save:

function saveState() {
  localStorage.setItem('kanban-state', JSON.stringify({ boards, activeBoard: boardId }));
}
Enter fullscreen mode Exit fullscreen mode

The whole app state is small (kilobytes even with many cards), so serializing on every change is fine. For apps that could grow large, you'd debounce or diff — but here, immediate saves keep the UX simple.

Series

This is entry #57 in my 100+ public portfolio series.

Top comments (0)