Browse by section

Web Design

UI Implementation for Kanban Boards and Task Management with Swapy [JavaScript]

Building drag-to-swap UI with Swapy: the two data attributes, swap versus reorder, dragOnHold for mobile, saving in onSwapEnd, and why native drag and drop fails in Firefox.

Swapy turns an existing layout into a drag-to-swap interface by adding two attributes—data-swapy-slot and data-swapy-item. It is framework-agnostic.

One property matters up front: Swapy performs a swap, not a reorder. Slot positions stay fixed and only their contents exchange. Missing that leads to mismatched expectations.

This article was published in 2024 and completely rewritten in September 2026. The original code never used Swapy at all—it was plain HTML5 drag and drop, so the title and content did not match. That code also had a bug that prevents it working in Firefox, covered later.

Sponsored

What Swapy is

Its strength is that you can retrofit drag-to-swap without changing your HTML structure.

Aspect Detail
Model Swap (one-for-one exchange)
Dependencies None (framework-agnostic)
Setup Two attributes plus one line
Touch Supported (Pointer Events based)
Animation dynamic / spring / none

Swap versus reorder

This is the most misunderstood point.

  • Swap (Swapy): move A onto C and C moves to where A was. The number of positions never changes
  • Reorder (sortable): move A onto C and B and C each shift up by one

Swapping suits dashboard widget arrangement and grid layouts. Kanban-style operations—”insert this task third in the In Progress column”—are not something swapping can express. Choose based on the interaction you need.

Getting started

From a CDN, the UMD build exposes a global Swapy.

<script src="https://unpkg.com/swapy@1.0.5/dist/swapy.min.js"></script>
<script>
  const container = document.querySelector('.container');
  const swapy = Swapy.createSwapy(container);
</script>

Via npm, import it as an ES module.

npm install swapy
import { createSwapy } from 'swapy';

const container = document.querySelector('.container');
const swapy = createSwapy(container);

Pin the version. Omitting it, as in https://unpkg.com/swapy/dist/..., means a major release can break your page without warning.

Sponsored

Basic implementation

The structure is slots (positions) containing items (contents).

<div class="container">
  <div class="slot" data-swapy-slot="a">
    <div class="card" data-swapy-item="1">Card 1</div>
  </div>

  <div class="slot" data-swapy-slot="b">
    <div class="card" data-swapy-item="2">Card 2</div>
  </div>

  <div class="slot" data-swapy-slot="c">
    <div class="card" data-swapy-item="3">Card 3</div>
  </div>
</div>
.container {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
}

.slot {
  min-height: 120px;
}

.card {
  height: 100%;
  display: grid;
  place-items: center;
  background: #f2f2f2;
  border: 1px solid #ccc;
  border-radius: 8px;
  cursor: grab;
  user-select: none;   /* stop text selection while dragging */
}

.card:active {
  cursor: grabbing;
}
const container = document.querySelector('.container');

const swapy = Swapy.createSwapy(container, {
  animation: 'dynamic',
});

Three rules to follow:

  • Slot names must be unique within the container
  • Item names must be unique too—they are your data identifiers
  • Exactly one item directly inside each slot. That structure is assumed

Options

const swapy = Swapy.createSwapy(container, {
  animation: 'dynamic',      // 'dynamic' | 'spring' | 'none'
  swapMode: 'hover',         // 'hover' | 'drop'
  dragAxis: 'both',          // 'x' | 'y' | 'both'
  autoScrollOnDrag: true,    // scroll when dragging to an edge
  dragOnHold: false,         // require a long press to start
  enabled: true,
  manualSwap: false,         // control the swap yourself
});
Option Effect Use when
swapMode: 'hover' Swaps as soon as you hover You want instant feedback
swapMode: 'drop' Commits on release Reducing accidental swaps
dragAxis: 'y' Vertical only Single-column lists
dragOnHold: true Long press to begin Avoiding conflict with mobile scrolling
autoScrollOnDrag: true Auto-scroll at the edges Long lists

dragOnHold: true is close to essential on mobile. Without it, a finger trying to scroll grabs a card instead.

Limiting the drag area

To make only part of a card draggable, use data-swapy-handle.

<div class="slot" data-swapy-slot="a">
  <div class="card" data-swapy-item="1">
    <span class="handle" data-swapy-handle>⠿</span>
    <a href="/task/1">View task details</a>
  </div>
</div>

Use a handle whenever the card contains links or buttons. Otherwise clicking a link turns into a drag.

Sponsored

Persisting the order

Use onSwapEnd. Its hasChanged flag lets you skip pointless requests.

const swapy = Swapy.createSwapy(container, { animation: 'dynamic' });

swapy.onSwapEnd(async (event) => {
  // picked up and put back — nothing to do
  if (!event.hasChanged) return;

  // [{ slot: 'a', item: '3' }, { slot: 'b', item: '1' }, ...]
  const layout = event.slotItemMap.asArray;

  try {
    const res = await fetch('/api/layout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ layout }),
      signal: AbortSignal.timeout(5000),
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
  } catch (err) {
    console.error('Failed to save the layout', err);
    showToast('Could not save');
  }
});

Three shapes are available:

event.slotItemMap.asArray;   // [{ slot, item }] — best for sending (order preserved)
event.slotItemMap.asObject;  // { a: '3', b: '1' } — best for lookups
event.slotItemMap.asMap;     // a Map

The current state is always available via swapy.slotItemMap().

Which event to use

Event Fires Use for
onSwapStart When dragging begins Revealing a delete zone
onBeforeSwap Just before a swap Return false to cancel it
onSwap On every swap Live preview
onSwapEnd When dragging ends Saving

Do not save in onSwap. With swapMode: 'hover' it fires repeatedly while dragging—ten requests for one gesture.

Blocking specific swaps

swapy.onBeforeSwap((event) => {
  const target = container.querySelector(`[data-swapy-slot="${event.toSlot}"]`);
  if (target?.dataset.locked === 'true') {
    return false;   // cancel the swap
  }
  return true;
});

Call update() after adding elements

Forget this and newly added cards are not draggable.

function addCard(slotName, itemId, text) {
  const slot = document.createElement('div');
  slot.className = 'slot';
  slot.dataset.swapySlot = slotName;

  const card = document.createElement('div');
  card.className = 'card';
  card.dataset.swapyItem = itemId;
  card.textContent = text;

  slot.appendChild(card);
  container.appendChild(slot);

  swapy.update();   // required
}

Release listeners with destroy() when leaving the view—essential in an SPA.

swapy.destroy();

To disable it temporarily, use enable(false).

swapy.enable(false);  // leaving edit mode
swapy.enable(true);   // entering edit mode

If you write plain HTML5 drag and drop instead

Doing it without a library is a valid choice, but the native API has traps you will hit.

1. Without setData(), it does not work in Firefox

The most common failure. If dragging works in Chrome but not Firefox, this is almost always why.

card.addEventListener('dragstart', (e) => {
  // without this, Firefox will not start the drag
  e.dataTransfer.setData('text/plain', card.dataset.id);
  e.dataTransfer.effectAllowed = 'move';
});

2. Do not set display: none on dragstart

// hiding the dragged element can abort the drag
card.addEventListener('dragstart', () => {
  setTimeout(() => { card.style.display = 'none'; }, 0);
});

// fade it instead
card.addEventListener('dragstart', () => {
  requestAnimationFrame(() => card.classList.add('is-dragging'));
});
.card.is-dragging { opacity: .4; }

3. preventDefault() in dragover is mandatory

drop never fires unless dragover calls preventDefault(). This is specified behaviour, and forgetting it produces “nothing happens when I drop”.

4. It does not work on touch devices

HTML5 drag and drop generally does not function in mobile browsers. For touch support you build on Pointer Events or use a library such as Swapy.

5. It is not keyboard accessible

Drag and drop alone excludes anyone who cannot use a mouse or touch. If reordering is essential, provide buttons.

<div class="card" data-swapy-item="1">
  <span data-swapy-handle>⠿</span>
  Task name
  <button type="button" class="move-up" aria-label="Move up">↑</button>
  <button type="button" class="move-down" aria-label="Move down">↓</button>
</div>

Announce changes to screen readers as well.

<p role="status" aria-live="polite" class="visually-hidden"></p>
swapy.onSwapEnd((event) => {
  if (!event.hasChanged) return;
  status.textContent = 'Order updated';
});

Choosing a tool

Goal Use
Exchange two positions (dashboards) Swapy
Insert into a list and reflow (Kanban) A sortable library
Accepting dropped files Native drag and drop is fine
Free positioning Build it on Pointer Events

The Pointer Events approach with setPointerCapture() is covered in Element methods in JavaScript.

Summary

  • Swapy needs data-swapy-slot, data-swapy-item and one createSwapy() call
  • It swaps rather than reflowing a list. Match it to the interaction you need
  • Use dragOnHold: true on mobile and swapMode: 'drop' to reduce mistakes
  • Use data-swapy-handle when cards contain links
  • Save in onSwapEnd and check hasChanged—saving in onSwap floods your API
  • Call update() after adding elements and destroy() when leaving
  • With native drag and drop, dataTransfer.setData() is required or Firefox does nothing
  • Native drag and drop does not work on touch; provide a keyboard alternative

“Reordering UI” covers two different interactions. Decide whether you are swapping or inserting before choosing a library.