Implementing Drag-and-Drop API on a Website
The native Drag and Drop API in the browser is not the most pleasant interface to work with. The order of events is not obvious, dataTransfer behaves differently across browsers, and dragover needs to be prevented by default, otherwise drop won't work. However, it's built into the browser, supports dragging files from the OS, works without libraries, and doesn't bloat the bundle.
For complex sortable lists with touch support and animations — look at @dnd-kit/core. For file uploads and basic drag-and-drop — the native API is sufficient.
Main Events
The order of events when dragging:
dragstart → dragenter → dragover (every ~50ms) → drop → dragend
On the source element: dragstart, drag, dragend.
On the target element: dragenter, dragover, dragleave, drop.
Draggable Element
function makeDraggable(element: HTMLElement, data: Record<string, string>): void {
element.setAttribute('draggable', 'true')
element.addEventListener('dragstart', (event: DragEvent) => {
if (!event.dataTransfer) return
// Set data for transfer
for (const [type, value] of Object.entries(data)) {
event.dataTransfer.setData(type, value)
}
// Operation type: copy | move | link
event.dataTransfer.effectAllowed = 'move'
// Custom drag ghost
const ghost = element.cloneNode(true) as HTMLElement
ghost.style.cssText = 'position:absolute;top:-9999px;opacity:0.8'
document.body.appendChild(ghost)
event.dataTransfer.setDragImage(ghost, 0, 0)
setTimeout(() => document.body.removeChild(ghost), 0)
element.classList.add('is-dragging')
})
element.addEventListener('dragend', () => {
element.classList.remove('is-dragging')
})
}
Drop Zone
function makeDropZone(
zone: HTMLElement,
onDrop: (data: string, event: DragEvent) => void,
acceptType = 'text/plain'
): void {
// Without preventDefault() here drop won't work
zone.addEventListener('dragover', (event: DragEvent) => {
if (!event.dataTransfer?.types.includes(acceptType)) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
zone.classList.add('drop-zone--active')
})
zone.addEventListener('dragleave', (event: DragEvent) => {
// Check that the cursor actually left the zone (didn't enter a child element)
if (!zone.contains(event.relatedTarget as Node)) {
zone.classList.remove('drop-zone--active')
}
})
zone.addEventListener('drop', (event: DragEvent) => {
event.preventDefault()
zone.classList.remove('drop-zone--active')
const data = event.dataTransfer?.getData(acceptType)
if (data) onDrop(data, event)
})
}
File Upload via Drag
function makeFileDropZone(
zone: HTMLElement,
onFiles: (files: FileList) => void,
accept?: string[]
): void {
zone.addEventListener('dragover', (event: DragEvent) => {
if (!event.dataTransfer?.types.includes('Files')) return
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
zone.classList.add('drop-zone--active')
})
zone.addEventListener('dragleave', (event: DragEvent) => {
if (!zone.contains(event.relatedTarget as Node)) {
zone.classList.remove('drop-zone--active')
}
})
zone.addEventListener('drop', (event: DragEvent) => {
event.preventDefault()
zone.classList.remove('drop-zone--active')
const files = event.dataTransfer?.files
if (!files?.length) return
if (accept) {
const filtered = Array.from(files).filter((f) =>
accept.some((type) =>
type.startsWith('.') ? f.name.endsWith(type) : f.type.startsWith(type.replace('*', ''))
)
)
if (!filtered.length) return
const dt = new DataTransfer()
filtered.forEach((f) => dt.items.add(f))
onFiles(dt.files)
} else {
onFiles(files)
}
})
}
Sortable List
Classic pattern — dragging cards to change order:
interface SortableItem {
id: string
element: HTMLElement
}
class SortableList {
private items: SortableItem[] = []
private draggedId: string | null = null
constructor(
private container: HTMLElement,
private onChange: (ids: string[]) => void
) {}
add(id: string, element: HTMLElement): void {
element.setAttribute('draggable', 'true')
element.dataset.id = id
element.addEventListener('dragstart', (e: DragEvent) => {
this.draggedId = id
e.dataTransfer!.setData('text/plain', id)
e.dataTransfer!.effectAllowed = 'move'
element.classList.add('sortable--dragging')
})
element.addEventListener('dragend', () => {
element.classList.remove('sortable--dragging')
this.draggedId = null
this.container.querySelectorAll('.sortable--over').forEach((el) =>
el.classList.remove('sortable--over')
)
})
element.addEventListener('dragover', (e: DragEvent) => {
e.preventDefault()
if (this.draggedId === id) return
element.classList.add('sortable--over')
// Insert dragged element before current
const draggedEl = this.container.querySelector(`[data-id="${this.draggedId}"]`)
if (draggedEl && draggedEl !== element) {
const rect = element.getBoundingClientRect()
const insertBefore = e.clientY < rect.top + rect.height / 2
element.parentNode?.insertBefore(
draggedEl,
insertBefore ? element : element.nextSibling
)
}
})
element.addEventListener('dragleave', () => {
element.classList.remove('sortable--over')
})
element.addEventListener('drop', (e: DragEvent) => {
e.preventDefault()
element.classList.remove('sortable--over')
// Order already updated in dragover, here we notify the outside
const newOrder = Array.from(
this.container.querySelectorAll('[data-id]')
).map((el) => (el as HTMLElement).dataset.id!)
this.onChange(newOrder)
})
this.items.push({ id, element })
this.container.appendChild(element)
}
}
React Hook for Drag-and-Drop
function useDraggable(id: string) {
const [isDragging, setIsDragging] = useState(false)
const dragHandlers = {
draggable: true as const,
onDragStart: (e: React.DragEvent) => {
e.dataTransfer.setData('text/plain', id)
e.dataTransfer.effectAllowed = 'move'
setIsDragging(true)
},
onDragEnd: () => setIsDragging(false),
}
return { isDragging, dragHandlers }
}
function useDroppable(onDrop: (id: string) => void) {
const [isOver, setIsOver] = useState(false)
const dropHandlers = {
onDragOver: (e: React.DragEvent) => {
e.preventDefault()
setIsOver(true)
},
onDragLeave: (e: React.DragEvent) => {
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) {
setIsOver(false)
}
},
onDrop: (e: React.DragEvent) => {
e.preventDefault()
setIsOver(false)
const id = e.dataTransfer.getData('text/plain')
if (id) onDrop(id)
},
}
return { isOver, dropHandlers }
}
Touch Devices
Native DnD on iOS doesn't work on most elements. For touch support, you need a polyfill (drag-touch) or library @dnd-kit/core, which uses the Pointer Events API and works on all devices. The choice depends on project requirements.
What's Included
Implementation of draggable elements and drop zones, sortable list (if needed), file upload via drag with type filtering, React hooks, CSS styles for drag states, solving the touch support issue.
Timeline: 1–2 days depending on scenario complexity and need for touch support.







