Drag and drop without an external library
A Kanban board with columns, draggable cards and persistent state can be fully implemented with Alpine.js and the native HTML5 Drag and Drop API. No SortableJS, no jQuery UI, not a single extra kilobyte of JavaScript, yet full control over behavior, accessibility and the appearance of the board.
Table of Contents
- 1. Why a Kanban board with Alpine.js makes sense
- 2. Basic structure: columns and cards as reactive state
- 3. Native Drag and Drop API: dragstart, dragover, drop
- 4. The complete Kanban board component
- 5. Visual feedback: highlighting drop zones
- 6. Sorting within a column
- 7. Persistence: storing board state in localStorage
- 8. Accessibility: a keyboard alternative to drag and drop
- 9. Kanban board approaches compared
- 10. Summary
- 11. FAQ
1. Why a Kanban board with Alpine.js makes sense
A Kanban board is one of those components where teams reflexively reach for a heavy library. SortableJS, jQuery UI Sortable, or an entire React drag and drop framework end up in the bundle just to move cards from one column to another. Yet every modern browser already ships with the HTML5 Drag and Drop API, which provides everything a working Kanban board needs. Combined with Alpine.js as a reactive state layer, the result is a board that requires no build step, no additional bundle and no framework overhead.
The appeal of a self built Kanban board lies in control. External drag and drop libraries bring their own event cycles, their own CSS classes and their own assumptions about the data structure. Anyone who wants to integrate a Kanban board into an existing Hyvä theme or a Magento admin interface quickly hits the limits of those assumptions. With Alpine.js the entire logic stays in one readable x-data component, and every adjustment is a direct edit of your own code instead of a configuration option of someone else's library.
This article builds a complete Kanban board from scratch: columns as containers, cards as draggable elements, native drag events for interaction and Alpine.js for reactive state. By the end there is a Kanban board that moves cards between columns, sorts within a column, persists its state in the browser and remains usable via keyboard alone.
2. Basic structure: columns and cards as reactive state
The data structure of a Kanban board is fundamentally simple: a list of columns, each column with a title and a list of cards. In Alpine.js this maps directly to a nested array inside the x-data object, with no separate state management library at all. Every card gets a unique id, a title and optionally further fields such as priority or assignee. This flat, serializable structure is the prerequisite for later writing the Kanban board painlessly into localStorage or a database.
It is important to model the columns themselves as an array of objects, not as fixed properties like todo, doing and done. With an array of column objects the Kanban board can later support any number of columns without touching the code. Each column references its cards through its own cards property, and the render loop uses two nested x-for directives: the outer one for columns, the inner one for cards within the current column.
// Kanban board state: columns as array, cards nested inside each column
function kanbanBoard() {
return {
columns: [
{
id: 'todo',
title: 'To Do',
cards: [
{ id: 1, title: 'Prepare sprint planning', priority: 'medium' },
{ id: 2, title: 'Update API documentation', priority: 'low' },
],
},
{
id: 'doing',
title: 'In Progress',
cards: [
{ id: 3, title: 'Reproduce checkout bug', priority: 'high' },
],
},
{
id: 'done',
title: 'Done',
cards: [],
},
],
// Find the column that currently holds a given card id
findColumnByCardId(cardId) {
return this.columns.find((col) => col.cards.some((c) => c.id === cardId));
},
};
}
This basic structure is already enough to render the Kanban board statically. The real challenge follows in the next step: cards need to move between columns, and that is exactly where the native drag and drop API comes in.
3. Native Drag and Drop API: dragstart, dragover, drop
The HTML5 Drag and Drop API consists of a handful of events that together cover the whole interaction for a Kanban board. dragstart fires as soon as an element with draggable="true" is dragged, and this is where the id of the dragged card gets stored in the event's DataTransfer object. dragover must be handled on every potential drop zone with event.preventDefault(), or the browser will categorically reject the drop. Only drop itself reads the previously stored card id and moves the card in the Alpine.js state.
A common beginner mistake with a Kanban board using the native drag and drop API: dragover gets ignored because it is assumed that drop would fire automatically. In reality the browser prevents every drop by default unless it is explicitly allowed via preventDefault() inside the dragover handler. A second common pitfall: dataTransfer.setData() expects a MIME type string as its first parameter, typically text/plain, and the value must be read back with the exact same type in the drop handler.
// Drag and drop handlers for a Kanban board card
function kanbanCard(cardId) {
return {
onDragStart(event) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(cardId));
// Slight delay so the drag image is captured before we style the source
requestAnimationFrame(() => {
event.target.classList.add('opacity-40');
});
},
onDragEnd(event) {
event.target.classList.remove('opacity-40');
},
};
}
// Drop zone handlers, shared by every column of the Kanban board
function kanbanDropzone(columnId) {
return {
onDragOver(event) {
event.preventDefault(); // required, or drop never fires
event.dataTransfer.dropEffect = 'move';
},
onDrop(event) {
event.preventDefault();
const cardId = Number(event.dataTransfer.getData('text/plain'));
this.moveCardToColumn(cardId, columnId);
},
};
}
4. The complete Kanban board component
With the basic structure and drag handlers in place, the complete Kanban board component can be assembled. The key piece is the moveCardToColumn method, which removes a card from its current column and appends it to the target column. Because Alpine.js relies on proxy based reactive state, a simple splice followed by push is enough for the view to update automatically, with no manual re render step needed.
The template side of the Kanban board uses two nested x-for loops and binds draggable, @dragstart, @dragover and @drop directly to the corresponding HTML elements. It is important to give every column and every card a stable :key in x-for, otherwise Alpine.js confuses elements during re rendering and the drag states get mixed up.
function kanbanBoard() {
return {
columns: [
{ id: 'todo', title: 'To Do', cards: [{ id: 1, title: 'Sprint planning', priority: 'medium' }] },
{ id: 'doing', title: 'In Progress', cards: [] },
{ id: 'done', title: 'Done', cards: [] },
],
draggedCardId: null,
onDragStart(event, cardId) {
this.draggedCardId = cardId;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(cardId));
},
onDrop(event, targetColumnId) {
event.preventDefault();
const cardId = Number(event.dataTransfer.getData('text/plain'));
this.moveCardToColumn(cardId, targetColumnId);
this.draggedCardId = null;
},
// Remove card from source column, push into target column
moveCardToColumn(cardId, targetColumnId) {
let movedCard = null;
this.columns.forEach((col) => {
const index = col.cards.findIndex((c) => c.id === cardId);
if (index !== -1) {
movedCard = col.cards.splice(index, 1)[0];
}
});
if (!movedCard) return;
const targetColumn = this.columns.find((col) => col.id === targetColumnId);
targetColumn.cards.push(movedCard);
},
};
}
5. Visual feedback: highlighting drop zones
A Kanban board without visual feedback while dragging feels unfinished, even if the functionality works flawlessly. Users expect a column to react visually as soon as a card is dragged over it, similar to Trello or Jira. With Alpine.js this can be solved with a simple dragOverColumn status variable, set on the dragenter event and cleared again on dragleave or drop.
The tricky part of Kanban board highlighting: dragleave also fires when moving between child elements within the same column, because the event is triggered on every element in the DOM tree. A reliable trick is to track the current target column by a unique id in the top level state instead of toggling CSS classes directly on individual elements, and to bind the highlighting via a :class attribute tied to exactly that one variable.
function kanbanBoard() {
return {
columns: [ /* ... */ ],
dragOverColumnId: null,
onDragEnter(columnId) {
this.dragOverColumnId = columnId;
},
onDragLeaveColumn(event, columnId) {
// Only clear if we actually left the column container, not a child
if (!event.currentTarget.contains(event.relatedTarget)) {
this.dragOverColumnId = null;
}
},
onDrop(event, columnId) {
event.preventDefault();
this.dragOverColumnId = null;
const cardId = Number(event.dataTransfer.getData('text/plain'));
this.moveCardToColumn(cardId, columnId);
},
};
}
In the template, :class="dragOverColumnId === column.id ? 'ring-2 ring-teal-400 bg-teal-50' : ''" on the column container is enough for the Kanban board to react visibly while dragging. This technique is considerably more robust than manipulating class lists directly through classList, because Alpine.js keeps the classes consistently in sync with the reactive state.
6. Sorting within a column
A productive Kanban board needs more than moving cards between columns, it also needs to change the order within a column via drag and drop. To achieve this, every card itself becomes a drop zone: on dragover over another card, the mouse position relative to the card's midpoint is used to determine whether the dragged card should be inserted before or after the target card.
The calculation uses the target card's getBoundingClientRect() and compares event.clientY against the vertical midpoint of the element. If the mouse is in the upper half of the card, the dragged card is inserted before it; in the lower half, after it. This Kanban board pattern matches exactly the behavior users know from Trello and GitHub Projects, and it can be implemented entirely without an external sorting library.
function kanbanBoard() {
return {
columns: [ /* ... */ ],
onDropOnCard(event, targetColumnId, targetCardId) {
event.preventDefault();
event.stopPropagation(); // prevent the column's own drop handler
const cardId = Number(event.dataTransfer.getData('text/plain'));
if (cardId === targetCardId) return;
const rect = event.currentTarget.getBoundingClientRect();
const insertAfter = event.clientY > rect.top + rect.height / 2;
let movedCard = null;
this.columns.forEach((col) => {
const idx = col.cards.findIndex((c) => c.id === cardId);
if (idx !== -1) movedCard = col.cards.splice(idx, 1)[0];
});
if (!movedCard) return;
const targetColumn = this.columns.find((col) => col.id === targetColumnId);
const targetIndex = targetColumn.cards.findIndex((c) => c.id === targetCardId);
const insertIndex = insertAfter ? targetIndex + 1 : targetIndex;
targetColumn.cards.splice(insertIndex, 0, movedCard);
},
};
}
7. Persistence: storing board state in localStorage
A Kanban board that resets to its starting state after every page reload is unusable for real work. Alpine.js solves this exact problem with the persist plugin: Alpine.$persist(defaultValue).as('key') replaces an ordinary state property with a variant that is automatically written to localStorage on every change and restores the most recently saved value on the next load.
For the Kanban board it is enough to manage the entire columns property through persist, since the array contains the full board structure. For boards with multiple users or server synchronization, localStorage is naturally only a client side stopgap, but for personal boards, internal tools or prototypes it is often sufficient and saves a full backend integration.
// Requires the Alpine persist plugin (Alpine.plugin(persist))
document.addEventListener('alpine:init', () => {
Alpine.data('kanbanBoard', () => ({
columns: Alpine.$persist([
{ id: 'todo', title: 'To Do', cards: [] },
{ id: 'doing', title: 'In Progress', cards: [] },
{ id: 'done', title: 'Done', cards: [] },
]).as('kanban-board-columns'),
moveCardToColumn(cardId, targetColumnId) {
// Same logic as before — persist plugin handles storage transparently
let movedCard = null;
this.columns.forEach((col) => {
const idx = col.cards.findIndex((c) => c.id === cardId);
if (idx !== -1) movedCard = col.cards.splice(idx, 1)[0];
});
if (!movedCard) return;
this.columns.find((c) => c.id === targetColumnId).cards.push(movedCard);
},
}));
});
8. Accessibility: a keyboard alternative to drag and drop
Pure drag and drop systematically excludes keyboard users and screen reader users, a problem many finished Kanban board libraries fail to solve as well. An accessible Kanban board needs a second, equally capable way to interact: buttons or keyboard shortcuts that move a focused card to the next or previous column without a mouse ever being involved.
In practice this can be solved with two small arrow buttons per card that call the same moveCardToColumn method used by the drag handler via @click. In addition, every card should carry aria-roledescription="draggable item" and an aria-label stating the current column name, so screen reader users understand where a card currently sits. This small addition turns a visually impressive Kanban board into a tool that is genuinely usable by everyone.
function kanbanBoard() {
return {
columns: [ /* ... */ ],
columnIndex(columnId) {
return this.columns.findIndex((c) => c.id === columnId);
},
// Keyboard-accessible alternative to native drag and drop
moveCardToAdjacentColumn(cardId, currentColumnId, direction) {
const currentIndex = this.columnIndex(currentColumnId);
const targetIndex = currentIndex + direction; // -1 or +1
if (targetIndex < 0 || targetIndex >= this.columns.length) return;
this.moveCardToColumn(cardId, this.columns[targetIndex].id);
},
};
}
9. Kanban board approaches compared
Before settling on the native drag and drop API, it is worth looking at the alternatives available for a Kanban board. Each variant has specific trade offs regarding bundle size, touch support and implementation effort.
| Approach | Bundle size | Touch support | Best fit |
|---|---|---|---|
| Native drag and drop plus Alpine.js | 0 KB extra | Only with extra code | Full control, small boards |
| SortableJS | about 13 KB gzip | Very good | Large boards, complex rules |
| jQuery UI Sortable | about 30 KB gzip with jQuery | Weak | Legacy projects only |
| Framework library (e.g. react-beautiful-dnd) | about 30 to 40 KB gzip | Very good | Only sensible inside React/Vue apps |
For most internal tools, admin areas and mid sized boards, the zero cost bundle size wins out for a Kanban board built with Alpine.js. Once native touch gestures on mobile devices become a hard requirement, the extra effort for pointer events or a lightweight touch polyfill addition pays off, since the native HTML5 Drag and Drop API has traditionally weak support on touchscreens.
Mironsoft
Alpine.js components for Hyvä, Magento and custom frontends
Need a custom Kanban board or another Alpine.js component?
We build tailored Alpine.js components, from drag and drop boards to data lists and complex forms, cleanly integrated into your existing Hyvä or Magento frontend.
Concept
Data model and interaction patterns for your board or component
Implementation
Native drag and drop, persistence and accessibility from a single source
Integration
Clean integration into existing Hyvä and Magento frontends
10. Summary
A complete Kanban board can be built with Alpine.js and the native HTML5 Drag and Drop API without loading a single extra library. The data structure consists of an array of columns with nested card arrays, the interaction runs through the events dragstart, dragover and drop, and Alpine.js, with its reactive proxy system, ensures every change to the data is immediately visible in the view.
For a production ready Kanban board, three additions are worth making: visual feedback through a drop zone status variable, sorting within a column via position calculation with getBoundingClientRect(), and a keyboard alternative for users who cannot or do not want to perform drag and drop. With the persist plugin, the entire board state is additionally saved permanently in the browser, with no backend integration at all.
Kanban Board with Alpine.js — The Essentials at a Glance
Data structure
Array of column objects, each column with its own cards array. Flat and serializable for persistence.
Drag events
dragstart stores the card id, dragover must call preventDefault(), drop moves the card.
Persistence
Alpine persist plugin automatically saves the entire board state in localStorage.
Accessibility
Arrow buttons as a keyboard alternative to drag and drop, aria-label with the current column per card.