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-itemand onecreateSwapy()call - It swaps rather than reflowing a list. Match it to the interaction you need
- Use
dragOnHold: trueon mobile andswapMode: 'drop'to reduce mistakes - Use
data-swapy-handlewhen cards contain links - Save in
onSwapEndand checkhasChanged—saving inonSwapfloods your API - Call
update()after adding elements anddestroy()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.