The HTML5 Drag and Drop API provides native support for drag and drop interactions. This article covers its usage and practical applications.
Basic Concepts
Making Elements Draggable
<!-- Add draggable attribute -->
<div draggable="true" id="drag-item">
Drag me
</div>
<!-- Images and links are draggable by default -->
<img src="image.jpg" alt="Draggable image">
<a href="https://example.com">Draggable link</a>
// Set draggable via JavaScript
const element = document.getElementById('item');
element.draggable = true;
Drag and Drop Events
const draggable = document.getElementById('drag-item');
const dropzone = document.getElementById('drop-zone');
// Drag start
draggable.addEventListener('dragstart', (e) => {
console.log('Drag started');
e.dataTransfer.setData('text/plain', e.target.id);
});
// During drag
draggable.addEventListener('drag', (e) => {
// Fires continuously during drag
});
// Drag end
draggable.addEventListener('dragend', (e) => {
console.log('Drag ended');
});
// Enter drop zone
dropzone.addEventListener('dragenter', (e) => {
e.preventDefault();
dropzone.classList.add('drag-over');
});
// Over drop zone
dropzone.addEventListener('dragover', (e) => {
e.preventDefault(); // Must prevent default to receive drop
e.dataTransfer.dropEffect = 'move';
});
// Leave drop zone
dropzone.addEventListener('dragleave', (e) => {
dropzone.classList.remove('drag-over');
});
// Drop
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
const id = e.dataTransfer.getData('text/plain');
const element = document.getElementById(id);
dropzone.appendChild(element);
dropzone.classList.remove('drag-over');
});
DataTransfer Object
draggable.addEventListener('dragstart', (e) => {
const dt = e.dataTransfer;
// Set data
dt.setData('text/plain', 'Plain text');
dt.setData('text/html', '<p>HTML content</p>');
dt.setData('application/json', JSON.stringify({ id: 1 }));
// Set drag effect
dt.effectAllowed = 'move'; // copy, move, link, all, none
// Set drag image
const img = new Image();
img.src = 'drag-image.png';
dt.setDragImage(img, 0, 0);
});
dropzone.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
// Get data
const text = dt.getData('text/plain');
const html = dt.getData('text/html');
const json = JSON.parse(dt.getData('application/json') || '{}');
// Get files
const files = dt.files;
// Get all items
const items = dt.items;
console.log('Drop effect:', dt.dropEffect);
});
Advanced Features
Custom Drag Preview
class DragPreview {
constructor(element, options = {}) {
this.element = element;
this.options = {
offsetX: 0,
offsetY: 0,
...options
};
element.draggable = true;
element.addEventListener('dragstart', (e) => this.handleDragStart(e));
}
handleDragStart(e) {
// Create custom preview
const preview = this.createPreview();
document.body.appendChild(preview);
e.dataTransfer.setDragImage(
preview,
this.options.offsetX,
this.options.offsetY
);
// Remove after drag starts
requestAnimationFrame(() => {
preview.remove();
});
}
createPreview() {
const preview = this.element.cloneNode(true);
preview.style.cssText = `
position: absolute;
top: -1000px;
left: -1000px;
opacity: 0.8;
transform: rotate(5deg);
`;
return preview;
}
}
// Usage
new DragPreview(document.querySelector('.draggable'), {
offsetX: 50,
offsetY: 25
});
Sortable List
class SortableList {
constructor(container) {
this.container = container;
this.items = container.querySelectorAll('.sortable-item');
this.draggedItem = null;
this.placeholder = this.createPlaceholder();
this.init();
}
createPlaceholder() {
const placeholder = document.createElement('div');
placeholder.className = 'sortable-placeholder';
return placeholder;
}
init() {
this.items.forEach(item => {
item.draggable = true;
item.addEventListener('dragstart', (e) => this.handleDragStart(e, item));
item.addEventListener('dragend', (e) => this.handleDragEnd(e, item));
item.addEventListener('dragover', (e) => this.handleDragOver(e, item));
item.addEventListener('dragenter', (e) => this.handleDragEnter(e, item));
});
this.container.addEventListener('dragover', (e) => {
e.preventDefault();
});
}
handleDragStart(e, item) {
this.draggedItem = item;
item.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
// Set placeholder size
this.placeholder.style.height = item.offsetHeight + 'px';
}
handleDragEnd(e, item) {
item.classList.remove('dragging');
this.placeholder.remove();
this.draggedItem = null;
}
handleDragOver(e, item) {
e.preventDefault();
if (item === this.draggedItem) return;
const rect = item.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
item.parentNode.insertBefore(this.placeholder, item);
item.parentNode.insertBefore(this.draggedItem, item);
} else {
item.parentNode.insertBefore(this.placeholder, item.nextSibling);
item.parentNode.insertBefore(this.draggedItem, item.nextSibling);
}
}
handleDragEnter(e, item) {
e.preventDefault();
}
getOrder() {
return Array.from(this.container.querySelectorAll('.sortable-item'))
.map(item => item.dataset.id);
}
}
// Usage
const sortable = new SortableList(document.querySelector('.sortable-list'));
Multi-Column Kanban Board
class KanbanBoard {
constructor(container) {
this.container = container;
this.columns = container.querySelectorAll('.kanban-column');
this.cards = container.querySelectorAll('.kanban-card');
this.draggedCard = null;
this.onCardMove = null;
this.init();
}
init() {
// Setup card dragging
this.cards.forEach(card => {
card.draggable = true;
card.addEventListener('dragstart', (e) => this.handleCardDragStart(e, card));
card.addEventListener('dragend', (e) => this.handleCardDragEnd(e, card));
});
// Setup column drop zones
this.columns.forEach(column => {
const dropzone = column.querySelector('.kanban-cards');
dropzone.addEventListener('dragover', (e) => this.handleDragOver(e, dropzone));
dropzone.addEventListener('dragenter', (e) => this.handleDragEnter(e, dropzone));
dropzone.addEventListener('dragleave', (e) => this.handleDragLeave(e, dropzone));
dropzone.addEventListener('drop', (e) => this.handleDrop(e, dropzone, column));
});
}
handleCardDragStart(e, card) {
this.draggedCard = card;
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', card.dataset.id);
// Add source column identifier
const sourceColumn = card.closest('.kanban-column');
e.dataTransfer.setData('source-column', sourceColumn.dataset.status);
}
handleCardDragEnd(e, card) {
card.classList.remove('dragging');
this.columns.forEach(col => {
col.querySelector('.kanban-cards').classList.remove('drag-over');
});
this.draggedCard = null;
}
handleDragOver(e, dropzone) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
// Calculate insert position
const afterElement = this.getInsertPosition(dropzone, e.clientY);
if (afterElement) {
dropzone.insertBefore(this.draggedCard, afterElement);
} else {
dropzone.appendChild(this.draggedCard);
}
}
handleDragEnter(e, dropzone) {
e.preventDefault();
dropzone.classList.add('drag-over');
}
handleDragLeave(e, dropzone) {
// Ensure leaving the entire dropzone
if (!dropzone.contains(e.relatedTarget)) {
dropzone.classList.remove('drag-over');
}
}
handleDrop(e, dropzone, column) {
e.preventDefault();
dropzone.classList.remove('drag-over');
const cardId = e.dataTransfer.getData('text/plain');
const sourceColumn = e.dataTransfer.getData('source-column');
const targetColumn = column.dataset.status;
// Trigger callback
if (this.onCardMove && sourceColumn !== targetColumn) {
this.onCardMove({
cardId,
from: sourceColumn,
to: targetColumn,
position: this.getCardPosition(dropzone, cardId)
});
}
}
getInsertPosition(dropzone, y) {
const cards = [...dropzone.querySelectorAll('.kanban-card:not(.dragging)')];
for (const card of cards) {
const rect = card.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (y < midY) {
return card;
}
}
return null;
}
getCardPosition(dropzone, cardId) {
const cards = dropzone.querySelectorAll('.kanban-card');
return Array.from(cards).findIndex(c => c.dataset.id === cardId);
}
}
// Usage
const board = new KanbanBoard(document.querySelector('.kanban-board'));
board.onCardMove = ({ cardId, from, to, position }) => {
console.log(`Card ${cardId} moved from ${from} to ${to}, position ${position}`);
// Update backend
updateCardStatus(cardId, to, position);
};
File Drop Upload
Basic File Upload
class FileDropzone {
constructor(element, options = {}) {
this.element = element;
this.options = {
multiple: true,
accept: '*/*',
maxSize: 10 * 1024 * 1024, // 10MB
onDrop: () => {},
onError: () => {},
...options
};
this.init();
}
init() {
this.element.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
this.element.classList.add('drag-over');
});
this.element.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
this.element.classList.remove('drag-over');
});
this.element.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
this.element.classList.remove('drag-over');
this.handleDrop(e);
});
// Prevent default page drop behavior
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(event => {
document.body.addEventListener(event, (e) => {
e.preventDefault();
e.stopPropagation();
});
});
}
handleDrop(e) {
const files = e.dataTransfer.files;
const validFiles = this.validateFiles(files);
if (validFiles.length > 0) {
this.options.onDrop(validFiles);
}
}
validateFiles(files) {
const validFiles = [];
const errors = [];
Array.from(files).forEach(file => {
// Check count
if (!this.options.multiple && validFiles.length >= 1) {
errors.push({ file, error: 'Only one file allowed' });
return;
}
// Check type
if (!this.matchType(file.type)) {
errors.push({ file, error: 'File type not supported' });
return;
}
// Check size
if (file.size > this.options.maxSize) {
errors.push({ file, error: 'File too large' });
return;
}
validFiles.push(file);
});
if (errors.length > 0) {
this.options.onError(errors);
}
return validFiles;
}
matchType(type) {
if (this.options.accept === '*/*') return true;
const accepts = this.options.accept.split(',').map(t => t.trim());
return accepts.some(accept => {
if (accept.startsWith('.')) {
// Extension match
return type.endsWith(accept.slice(1));
}
if (accept.endsWith('/*')) {
// MIME type prefix match
return type.startsWith(accept.slice(0, -1));
}
return type === accept;
});
}
}
// Usage
const dropzone = new FileDropzone(document.querySelector('.file-dropzone'), {
accept: 'image/*,.pdf',
maxSize: 5 * 1024 * 1024,
onDrop: (files) => {
console.log('Received files:', files);
uploadFiles(files);
},
onError: (errors) => {
errors.forEach(({ file, error }) => {
console.error(`${file.name}: ${error}`);
});
}
});
Image Upload with Preview
class ImageDropzone extends FileDropzone {
constructor(element, options = {}) {
super(element, {
accept: 'image/*',
onDrop: (files) => this.handleImages(files),
...options
});
this.previewContainer = options.previewContainer || element;
this.images = [];
}
handleImages(files) {
files.forEach(file => {
this.readAndPreview(file);
});
}
readAndPreview(file) {
const reader = new FileReader();
reader.onload = (e) => {
const imageData = {
file,
dataUrl: e.target.result,
id: crypto.randomUUID()
};
this.images.push(imageData);
this.renderPreview(imageData);
};
reader.readAsDataURL(file);
}
renderPreview(imageData) {
const preview = document.createElement('div');
preview.className = 'image-preview';
preview.dataset.id = imageData.id;
preview.innerHTML = `
<img src="${imageData.dataUrl}" alt="${imageData.file.name}">
<div class="image-info">
<span class="name">${imageData.file.name}</span>
<span class="size">${this.formatSize(imageData.file.size)}</span>
</div>
<button class="remove-btn" type="button">×</button>
`;
preview.querySelector('.remove-btn').addEventListener('click', () => {
this.removeImage(imageData.id);
});
this.previewContainer.appendChild(preview);
}
removeImage(id) {
this.images = this.images.filter(img => img.id !== id);
const preview = this.previewContainer.querySelector(`[data-id="${id}"]`);
preview?.remove();
}
formatSize(bytes) {
const units = ['B', 'KB', 'MB', 'GB'];
let index = 0;
let size = bytes;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index++;
}
return `${size.toFixed(1)} ${units[index]}`;
}
getImages() {
return this.images.map(img => img.file);
}
clear() {
this.images = [];
this.previewContainer.innerHTML = '';
}
}
// Usage
const imageDropzone = new ImageDropzone(
document.querySelector('.image-dropzone'),
{
previewContainer: document.querySelector('.image-previews'),
maxSize: 2 * 1024 * 1024
}
);
Touch Device Support
Touch Drag Adapter
class TouchDraggable {
constructor(element, options = {}) {
this.element = element;
this.options = {
onDragStart: () => {},
onDrag: () => {},
onDragEnd: () => {},
...options
};
this.isDragging = false;
this.startX = 0;
this.startY = 0;
this.currentX = 0;
this.currentY = 0;
this.init();
}
init() {
this.element.addEventListener('touchstart', (e) => this.handleTouchStart(e), { passive: false });
this.element.addEventListener('touchmove', (e) => this.handleTouchMove(e), { passive: false });
this.element.addEventListener('touchend', (e) => this.handleTouchEnd(e));
this.element.addEventListener('touchcancel', (e) => this.handleTouchEnd(e));
}
handleTouchStart(e) {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
this.startX = touch.clientX;
this.startY = touch.clientY;
// Long press to trigger drag
this.longPressTimer = setTimeout(() => {
this.isDragging = true;
this.element.classList.add('dragging');
this.options.onDragStart(this.element, touch);
}, 200);
}
handleTouchMove(e) {
if (!this.isDragging) {
const touch = e.touches[0];
const dx = Math.abs(touch.clientX - this.startX);
const dy = Math.abs(touch.clientY - this.startY);
// Cancel long press if moved too much
if (dx > 10 || dy > 10) {
clearTimeout(this.longPressTimer);
}
return;
}
e.preventDefault();
const touch = e.touches[0];
this.currentX = touch.clientX;
this.currentY = touch.clientY;
// Move element
const dx = this.currentX - this.startX;
const dy = this.currentY - this.startY;
this.element.style.transform = `translate(${dx}px, ${dy}px)`;
this.options.onDrag(this.element, touch, { dx, dy });
}
handleTouchEnd(e) {
clearTimeout(this.longPressTimer);
if (!this.isDragging) return;
this.isDragging = false;
this.element.classList.remove('dragging');
this.element.style.transform = '';
this.options.onDragEnd(this.element, {
x: this.currentX,
y: this.currentY
});
}
}
// Combined with native drag and drop
class UnifiedDraggable {
constructor(element, options = {}) {
this.element = element;
this.options = options;
if ('ontouchstart' in window) {
this.touchHandler = new TouchDraggable(element, options);
}
// Also support native drag and drop
element.draggable = true;
element.addEventListener('dragstart', (e) => options.onDragStart?.(element, e));
element.addEventListener('drag', (e) => options.onDrag?.(element, e));
element.addEventListener('dragend', (e) => options.onDragEnd?.(element, e));
}
}
Best Practices Summary
Drag and Drop API Best Practices:
┌─────────────────────────────────────────────────────┐
│ │
│ Interaction Design │
│ ├── Provide clear visual feedback │
│ ├── Show drop target areas │
│ ├── Use appropriate cursor styles │
│ └── Support keyboard accessibility │
│ │
│ Performance Optimization │
│ ├── Avoid complex operations in drag events │
│ ├── Use throttling for position updates │
│ ├── Set appropriate drag images │
│ └── Minimize DOM operations │
│ │
│ Compatibility │
│ ├── Handle touch devices │
│ ├── Provide fallback solutions │
│ ├── Test across browsers │
│ └── Consider mobile experience │
│ │
└─────────────────────────────────────────────────────┘
| Event | Trigger | Target |
|---|---|---|
| dragstart | Drag starts | Dragged element |
| drag | During drag | Dragged element |
| dragend | Drag ends | Dragged element |
| dragenter | Enter drop zone | Drop target |
| dragover | Over drop zone | Drop target |
| dragleave | Leave drop zone | Drop target |
| drop | Drop | Drop target |
Master the Drag and Drop API to create intuitive interaction experiences.