Kanban Board with Alpine.js: Building Drag and Drop Without a Library
AI generated
x-data
Alpine
Alpine.js · Drag and Drop · Case Study
Building a Kanban Board with Alpine.js
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.

22 min read x-data · x-for · dragstart · dragover · drop Alpine.js 3.x

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.

11. FAQ: Kanban Board with Alpine.js

1Do I need extra plugins?
Not for drag and drop. For persistence, the Alpine persist plugin is worthwhile.
2Why doesn't drop fire?
A missing preventDefault() in the dragover handler is the most common cause.
3Does it work on smartphones?
Native drag and drop is poorly supported on touchscreens, a pointer events fallback is recommended.
4How do I save state permanently?
With Alpine.$persist(defaultValue).as('key') for the columns property.
5How do I sort cards in a column?
Via getBoundingClientRect() and comparing the mouse position with the card midpoint.
6How do I make it usable without a mouse?
Arrow buttons calling the same moveCardToColumn method, plus aria-label per card.
7Performance difference to SortableJS?
Barely noticeable with a few hundred cards, virtual scrolling helps with thousands.
8Multiple boards on one page?
Yes, every x-data instance fully encapsulates its state, given distinct persist keys.
9Card in two columns at once?
splice removes the card from all columns first, before push adds it to the target column.
10Build it or use a library?
Build it for custom requirements and tight integration, use a proven library for very complex rules.