The JavaScript Drag & Drop library your grandparents warned you about.
—
Swappable extends Draggable to enable element swapping where dragging over another element exchanges their positions. Perfect for grid layouts and card arrangements where order is less important than positioning.
Creates a swappable instance with the same interface as Draggable but with element swapping behavior.
/**
* Creates a new swappable instance for element swapping
* @param containers - Elements that contain swappable items
* @param options - Configuration options (same as Draggable)
*/
class Swappable<T = SwappableEventNames> extends Draggable<T> {
constructor(containers: DraggableContainer, options?: DraggableOptions);
}Usage Example:
import { Swappable } from "@shopify/draggable";
const swappable = new Swappable(document.querySelectorAll('.card-grid'), {
draggable: '.card'
});
// Listen for swap events
swappable.on('swappable:swapped', (event) => {
console.log('Elements swapped');
console.log('Dragged element:', event.dragEvent.source);
console.log('Swapped with:', event.swappedElement);
});Swappable-specific events that fire during swap operations.
type SwappableEventNames =
| 'swappable:start'
| 'swappable:swap'
| 'swappable:swapped'
| 'swappable:stop'
| DraggableEventNames;Event Details:
Event Handlers Example:
swappable.on('swappable:start', (event) => {
console.log('Started swapping');
// Add visual feedback
event.dragEvent.source.classList.add('being-swapped');
});
swappable.on('swappable:swap', (event) => {
// This fires before the swap happens - you can cancel it
const source = event.dragEvent.source;
const target = event.over;
if (shouldPreventSwap(source, target)) {
event.cancel();
return;
}
// Add pre-swap effects
target.classList.add('about-to-swap');
});
swappable.on('swappable:swapped', (event) => {
// This fires after the elements have been swapped
const source = event.dragEvent.source;
const swapped = event.swappedElement;
// Update data model
updateElementPositions(source, swapped);
// Add visual feedback
swapped.classList.add('just-swapped');
setTimeout(() => {
swapped.classList.remove('just-swapped');
}, 500);
});
swappable.on('swappable:stop', (event) => {
// Clean up visual states
document.querySelectorAll('.being-swapped, .about-to-swap').forEach(el => {
el.classList.remove('being-swapped', 'about-to-swap');
});
});class SwappableEvent extends AbstractEvent {
readonly dragEvent: DragEvent;
}
class SwappableStartEvent extends SwappableEvent {}
class SwappableSwapEvent extends SwappableEvent {
readonly over: HTMLElement;
readonly overContainer: HTMLElement;
}
class SwappableSwappedEvent extends SwappableEvent {
readonly swappedElement: HTMLElement;
}
class SwappableStopEvent extends SwappableEvent {}Swappable has unique behavior compared to other draggable types:
import { Swappable } from "@shopify/draggable";
// Create swappable photo gallery
const photoSwappable = new Swappable(document.querySelector('.photo-grid'), {
draggable: '.photo-card',
classes: {
'source:dragging': 'photo-dragging',
'container:dragging': 'grid-active'
}
});
// Track swaps for undo functionality
let swapHistory = [];
photoSwappable.on('swappable:swapped', (event) => {
const source = event.dragEvent.source;
const target = event.swappedElement;
// Record swap for undo
swapHistory.push({
timestamp: Date.now(),
sourceId: source.dataset.photoId,
targetId: target.dataset.photoId,
sourceIndex: Array.from(source.parentNode.children).indexOf(source),
targetIndex: Array.from(target.parentNode.children).indexOf(target)
});
// Update photo metadata
updatePhotoOrder(source.dataset.photoId, target.dataset.photoId);
// Add swap animation
source.style.transform = 'scale(1.1)';
target.style.transform = 'scale(1.1)';
setTimeout(() => {
source.style.transform = '';
target.style.transform = '';
}, 200);
});
// Undo last swap
function undoLastSwap() {
const lastSwap = swapHistory.pop();
if (!lastSwap) return;
const sourceEl = document.querySelector(`[data-photo-id="${lastSwap.sourceId}"]`);
const targetEl = document.querySelector(`[data-photo-id="${lastSwap.targetId}"]`);
if (sourceEl && targetEl) {
// Swap them back
swapElements(sourceEl, targetEl);
updatePhotoOrder(lastSwap.sourceId, lastSwap.targetId);
}
}
// Utility function to swap DOM elements
function swapElements(el1, el2) {
const temp = document.createElement('div');
el1.parentNode.insertBefore(temp, el1);
el2.parentNode.insertBefore(el1, el2);
temp.parentNode.insertBefore(el2, temp);
temp.remove();
}const conditionalSwappable = new Swappable(containers, {
draggable: '.player-card'
});
conditionalSwappable.on('swappable:swap', (event) => {
const source = event.dragEvent.source;
const target = event.over;
// Only allow swapping within same team
const sourceTeam = source.dataset.team;
const targetTeam = target.dataset.team;
if (sourceTeam !== targetTeam) {
event.cancel();
showError('Cannot swap players between teams');
}
});const gridSwappable = new Swappable(document.querySelector('.puzzle-grid'), {
draggable: '.puzzle-piece',
classes: {
'source:dragging': 'piece-moving',
'draggable:over': 'piece-target'
}
});
// Track grid positions
gridSwappable.on('swappable:swapped', (event) => {
const source = event.dragEvent.source;
const target = event.swappedElement;
// Update grid coordinates
const sourcePos = getGridPosition(source);
const targetPos = getGridPosition(target);
setGridPosition(source, targetPos);
setGridPosition(target, sourcePos);
// Check if puzzle is solved
if (isPuzzleSolved()) {
celebrateSolution();
}
});
function getGridPosition(element) {
return {
row: parseInt(element.dataset.row),
col: parseInt(element.dataset.col)
};
}
function setGridPosition(element, position) {
element.dataset.row = position.row;
element.dataset.col = position.col;
element.style.gridArea = `${position.row} / ${position.col}`;
}const multiSwappable = new Swappable([
document.querySelector('.team-a'),
document.querySelector('.team-b'),
document.querySelector('.bench')
], {
draggable: '.player'
});
multiSwappable.on('swappable:swapped', (event) => {
const source = event.dragEvent.source;
const target = event.swappedElement;
const sourceContainer = source.closest('.team-a, .team-b, .bench');
const targetContainer = target.closest('.team-a, .team-b, .bench');
// Handle cross-team swaps
if (sourceContainer !== targetContainer) {
updatePlayerTeam(source.dataset.playerId, targetContainer.dataset.team);
updatePlayerTeam(target.dataset.playerId, sourceContainer.dataset.team);
}
// Update team rosters
updateTeamRosters();
});Install with Tessl CLI
npx tessl i tessl/npm-shopify--draggable