Kanban
Gentelella's kanban board uses native HTML5 drag-and-drop — no library. Cards drag between columns, click any card to edit in a modal. State lives in memory; persistence is a 20-line swap-in.
Last updated May 22, 2026
The kanban board at production/kanban.html is a working drag-and-drop demo with four columns, labelled cards, assignees, due dates, and a card-detail modal. Everything is HTML5 native — draggable="true", dragstart/dragover/drop events, no react-dnd, no @dnd-kit, no Sortable.js.
The implementation lives in src/v4/kanban.js — ~360 lines.
What’s wired
| Feature | How |
|---|---|
| 4 columns: To do / In progress / Review / Done | Defined in the COLUMNS constant |
| Drag a card between columns | Native HTML5 drag, state updates in drop handler |
| Drop indicator (highlighted column) | .drop-target class added on dragenter, removed on dragleave/dragend |
| Click a card | Opens edit modal (title, description, labels, assignees, due date, priority) |
| Save/Delete from modal | Updates the in-memory card array, repaints |
| Add a new card | ”+ Add card” button at the bottom of each column |
| Labels with color | 6 colors via --green, --red, --purple, etc. tokens |
| Assignee avatars | Initials + color, stack of up to 3 per card |
| Priority indicator | Left border color: red (high), yellow (medium), nothing (low) |
State model
A flat array of card objects, no nesting by column:
const cards = [
{
id: 1,
col: 'doing', // 'todo' | 'doing' | 'review' | 'done'
title: 'Implement drag-and-drop kanban',
desc: 'Use HTML5 drag API, no library',
labels: ['eng'], // refs into LABELS array
assignees: ['MR'], // refs into ASSIGNEES map
due: 'Apr 30',
priority: 'high' // 'high' | 'medium' | 'low'
},
// ...
];
Rendering is column-major: for each column, filter cards by col, render. Simple and fast for the demo’s ~20 cards. For a real board with hundreds of cards, you’d want to virtualize the columns or paginate — but for the template’s purposes, full re-render on every mutation stays under a frame.
How the drag-and-drop works
Three events, all on the board container (event delegation), with one state variable:
let draggedId = null;
function setupDnD(boardEl) {
// ─── 1. Start drag ────────────────────────
boardEl.addEventListener('dragstart', (e) => {
const card = e.target.closest('.kanban-card');
if (!card) return;
draggedId = parseInt(card.dataset.id, 10);
card.classList.add('dragging');
// Firefox needs data set on dataTransfer to actually drag.
e.dataTransfer.setData('text/plain', String(draggedId));
});
// ─── 2. End drag (cleanup) ────────────────
boardEl.addEventListener('dragend', (e) => {
const card = e.target.closest('.kanban-card');
if (card) card.classList.remove('dragging');
document.querySelectorAll('.kanban-column-body.drop-target')
.forEach((el) => el.classList.remove('drop-target'));
draggedId = null;
});
// ─── 3. Allow drop on column bodies ────────
boardEl.addEventListener('dragover', (e) => {
const col = e.target.closest('.kanban-column-body');
if (col) {
e.preventDefault(); // Required to allow drop
col.classList.add('drop-target');
}
});
// ─── 4. Drop ───────────────────────────────
boardEl.addEventListener('drop', (e) => {
const col = e.target.closest('.kanban-column-body');
if (!col || draggedId === null) return;
e.preventDefault();
const newColumn = col.dataset.drop;
const card = cards.find((c) => c.id === draggedId);
if (!card) return;
card.col = newColumn;
render(); // Repaint everything
showToast(`Moved to ${columnTitle(newColumn)}`, { variant: 'success' });
});
}
A few details worth knowing:
e.preventDefault()ondragoveris required by the spec to allow a drop. Without it the drop event never fires.dataTransfer.setDatais for Firefox. Modern Chrome/Safari work fine with just the closure variable; Firefox needs something set on dataTransfer for the drag to be valid.- Event delegation on the board container means new cards work without rebinding handlers. Adding a card via the modal just appends to the array and re-renders.
The card edit modal
Click any card → showModal() opens with the card’s contents in a form:
function openCardModal(card) {
showModal({
title: card.title,
body: renderEditForm(card),
actions: [
{ label: 'Delete', variant: 'danger', onClick: () => { deleteCard(card.id); } },
{ label: 'Cancel', variant: 'secondary', dismiss: true },
{ label: 'Save', variant: 'primary',
onClick: ({ formData }) => { updateCard(card.id, formData); } }
]
});
}
The modal is the same generic showModal() exported from src/v4/modal.js — the kanban just passes it different form HTML. See Component playground for the modal’s full API.
Customizing columns
Edit the COLUMNS constant at the top of kanban.js:
const COLUMNS = [
{ id: 'todo', title: 'To do', color: 'var(--text-muted)' },
{ id: 'doing', title: 'In progress', color: 'var(--blue)' },
{ id: 'review', title: 'Review', color: 'var(--yellow)' },
{ id: 'done', title: 'Done', color: 'var(--green)' }
];
The id is what cards[].col references. The color is used for the column header underline. To add a fifth column, append to this array; existing cards stay put unless you explicitly move them.
To allow runtime column reordering, you’d need to drag-and-drop the column headers themselves. The template doesn’t ship this, but the same HTML5 drag-and-drop pattern works — listen for dragstart on .kanban-column, reorder COLUMNS, re-render.
Labels and assignees
Both are lookup maps:
const LABELS = [
{ id: 'design', text: 'Design', color: 'purple' },
{ id: 'eng', text: 'Eng', color: 'blue' },
{ id: 'pm', text: 'PM', color: 'green' },
{ id: 'bug', text: 'Bug', color: 'red' },
{ id: 'urgent', text: 'Urgent', color: 'red' },
{ id: 'docs', text: 'Docs', color: 'yellow' }
];
const ASSIGNEES = {
SK: { name: 'Sarah Kowalski', color: 'azure' },
MR: { name: 'Michael Reyes', color: 'purple' },
EW: { name: 'Emily Wang', color: 'yellow' },
// ...
};
Cards reference labels and assignees by id (labels: ['eng'], assignees: ['MR']). The render functions resolve through getLabel(id) / ASSIGNEES[code] to display the pill or avatar.
Adding a new label is appending to LABELS. Adding an assignee is adding a key to ASSIGNEES. The card modal’s label/assignee pickers reflect whatever’s in those structures.
Persistence
The board has no persistence by default. Refresh the page and you’re back to the seed cards.
To persist to localStorage, wrap the mutations:
const STORAGE_KEY = 'gentelella:kanban';
function save() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(cards)); } catch (_e) {}
}
function load() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (_e) { return null; }
}
// On init:
const stored = load();
if (stored) cards.splice(0, cards.length, ...stored);
// After every mutation (drop, save, delete, add):
save();
For server-side persistence, use the same data-adapter.js pattern documented in Inbox — POST changes to your API, GET the initial state on load.
Accessibility note
HTML5 drag-and-drop is famously not keyboard accessible. The native draggable="true" only responds to mouse and touch events. Users on keyboards (or assistive tech) cannot move cards in the demo.
For a production-grade accessible kanban, you’d need to:
- Add keyboard handlers for picking up, moving, and dropping cards (e.g.
Spaceto pick, arrows to move,Spaceagain to drop) - Announce moves via an
aria-liveregion - Provide a button-based “Move to column…” menu as an alternative to drag
The template ships the click-to-edit modal as a partial mitigation — keyboard users can change a card’s column via the form. But the drag-and-drop UX itself is mouse-only by design. Libraries like @dnd-kit solve this comprehensively; the template prefers zero dependencies at the cost of accessibility for the headline drag interaction.
See also
- Inbox — the other major interactive component
- Component playground — see the modal, button, and badge components the kanban composes from
- Theming — every column color, label color, and priority indicator reads from
--*tokens