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
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;
}),
};
}
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);
}
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:
-
dragstarton source → setdataTransfer, add a CSS class -
dragenteron target → first time cursor enters -
dragoveron target → fires continuously while hovering. MustpreventDefault()or drop won't work. -
dragleaveon target → cursor leaves (fires on child element boundaries too — watch out) -
dropon target → set final state -
dragendon 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
});
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 }));
}
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.
- 📦 Repo: https://github.com/sen-ltd/kanban-local
- 🌐 Live: https://sen.ltd/portfolio/kanban-local/
- 🏢 Company: https://sen.ltd/

Top comments (0)