The definitive guide to drag and drop in JavaScript
By Flavio Copes
Learn the HTML Drag and Drop API with draggable elements, drop zones, DataTransfer, file drops, accessible reordering, and reliable event handling.
The HTML Drag and Drop API lets users drag data from one place and drop it somewhere else.
You can use it to reorder cards, move an item between lists, or accept files dragged from the desktop.
The API is built around events and a DataTransfer object. It works well with a mouse or trackpad, but native HTML drag and drop is not a complete touch interaction. We will add an accessible keyboard alternative instead of assuming every user can drag.
Let’s start with the smallest working example.
Make an element draggable
Add the draggable attribute:
<div id="card" draggable="true">Write the guide</div>
Listen for dragstart and put data into the drag operation:
const card = document.querySelector('#card')
card.addEventListener('dragstart', (event) => {
event.dataTransfer.setData('text/plain', card.id)
})
Images, links, and selected text are draggable by default. Most other HTML elements need draggable="true".
The attribute is enumerated, not boolean. Write draggable="true" or draggable="false". A bare draggable attribute is not the same pattern as disabled or checked.
Create a drop zone
Add a destination:
<section id="done">
<h2>Done</h2>
</section>
By default, most elements do not accept a drop. Cancel the dragover event to make the section a valid target:
const done = document.querySelector('#done')
done.addEventListener('dragover', (event) => {
event.preventDefault()
})
Then handle drop:
done.addEventListener('drop', (event) => {
event.preventDefault()
const cardId = event.dataTransfer.getData('text/plain')
const card = document.getElementById(cardId)
done.append(card)
})
This is the complete core interaction: make a source draggable, allow a target to receive a drop, and transfer enough data to finish the operation.
Understand the drag event sequence
The source receives these events:
dragstartwhen dragging beginsdragrepeatedly while dragging continuesdragendwhen the operation finishes or is canceled
Potential targets receive:
dragenterwhen the pointer entersdragoverrepeatedly while it remains over the targetdragleavewhen it leavesdropwhen the user releases over an allowed target
Do not perform expensive work in drag or dragover. They can fire many times.
Use dragstart to configure the operation. Use dragover to decide whether a target accepts it. Use drop for the actual change. Use dragend to remove temporary source state.
card.addEventListener('dragstart', () => {
card.classList.add('is-dragging')
})
card.addEventListener('dragend', () => {
card.classList.remove('is-dragging')
})
The DataTransfer object
Every drag event provides event.dataTransfer. It carries the data and describes the operation.
Its most useful members are:
setData()andgetData()for stringstypesfor the available formatsitemsfor drag data itemsfilesfor dropped fileseffectAllowedfor operations allowed by the sourcedropEffectfor the operation selected by the target
Drag data has a protected lifecycle. Set data during dragstart and read it during drop. Do not save the DataTransfer object and expect it to remain useful after the event.
Transfer strings with meaningful types
You can store more than one representation:
card.addEventListener('dragstart', (event) => {
event.dataTransfer.setData('text/plain', card.textContent)
event.dataTransfer.setData('text/x-card-id', card.id)
})
The drop target can inspect types before accepting:
done.addEventListener('dragover', (event) => {
if (event.dataTransfer.types.includes('text/x-card-id')) {
event.preventDefault()
}
})
Use standard MIME-like types where possible. A custom type is useful inside your application, but data dragged to another application may only understand text/plain, text/html, or a URL format.
Never treat dropped HTML as trusted. If you read text/html, sanitize it before inserting it into the page. Plain text is safer when formatting is not required.
Choose copy, move, or link
The source can say which effects are allowed:
card.addEventListener('dragstart', (event) => {
event.dataTransfer.effectAllowed = 'move'
})
The target selects the intended result during dragover:
done.addEventListener('dragover', (event) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
})
Common values for effectAllowed include copy, move, link, copyMove, and all. dropEffect uses copy, move, link, or none.
The operating system may also adjust the requested effect when the user holds a modifier key. Treat these properties as communication with the browser, not as proof that your application already moved anything.
Drag data across pages and applications
HTML drag and drop can cross more boundaries than a custom pointer interaction.
A user can drag selected text into a text editor, drag a link to the desktop, or drag a file from the operating system into your page. The receiving application decides which representation it understands.
When your page is the source, provide a useful standard format:
link.addEventListener('dragstart', (event) => {
event.dataTransfer.setData('text/plain', link.href)
event.dataTransfer.setData('text/uri-list', link.href)
})
When your page is the destination, inspect types and accept only what the feature expects. Do not cancel every drag because one target supports one custom format.
Cross-origin content is still untrusted. A string dragged from another page has the same security status as pasted or uploaded input.
Do not place secrets in drag data. The operation may leave your page, and another application may read a standard representation.
Show which target is active
Give the user a clear visual target:
done.addEventListener('dragenter', () => {
done.classList.add('can-drop')
})
done.addEventListener('dragleave', () => {
done.classList.remove('can-drop')
})
done.addEventListener('drop', () => {
done.classList.remove('can-drop')
})
dragenter and dragleave bubble. A target containing children can appear to flicker as the pointer crosses those children.
A small counter handles nested entries:
let entered = 0
done.addEventListener('dragenter', () => {
entered += 1
done.classList.add('can-drop')
})
done.addEventListener('dragleave', () => {
entered -= 1
if (entered === 0) {
done.classList.remove('can-drop')
}
})
done.addEventListener('drop', () => {
entered = 0
done.classList.remove('can-drop')
})
Also set a visible focus style for the keyboard controls we will add later.
Use one state class for the source and another for the valid destination. A target that cannot accept the current type should not light up.
If a drag is canceled with Escape, drop does not run. dragend still gives the source a place to clear its styles. Also clear target styles from dragleave and from any application-level reset.
Set a custom drag image
The browser normally shows a translucent image of the dragged element. You can replace it:
card.addEventListener('dragstart', (event) => {
const preview = document.querySelector('#drag-preview')
event.dataTransfer.setDragImage(preview, 20, 20)
})
The last two arguments are the pointer offset inside the image.
Keep the preview simple. It follows the pointer and should not obscure the destination.
Reorder a list
For a sortable list, store an item ID and calculate where it belongs at drop time.
<ul id="tasks">
<li id="task-1" draggable="true">Write the guide</li>
<li id="task-2" draggable="true">Review the examples</li>
<li id="task-3" draggable="true">Publish it</li>
</ul>
const tasks = document.querySelector('#tasks')
tasks.addEventListener('dragstart', (event) => {
const item = event.target.closest('li')
if (!item) return
event.dataTransfer.setData('text/x-task-id', item.id)
event.dataTransfer.effectAllowed = 'move'
})
tasks.addEventListener('dragover', (event) => {
if (event.dataTransfer.types.includes('text/x-task-id')) {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}
})
tasks.addEventListener('drop', (event) => {
event.preventDefault()
const draggedId = event.dataTransfer.getData('text/x-task-id')
const draggedItem = document.getElementById(draggedId)
const targetItem = event.target.closest('li')
if (!draggedItem || !targetItem || draggedItem === targetItem) return
tasks.insertBefore(draggedItem, targetItem)
})
A production sorter should show an insertion marker and decide before or after based on pointer position. The important part is to keep the data transfer small. Pass an ID, not serialized application state.
Update your real data model after the drop. Moving DOM nodes alone is not enough when a framework or server owns the list.
Accept files from the desktop
Files dragged from the operating system appear in dataTransfer.files:
const dropZone = document.querySelector('#file-drop')
dropZone.addEventListener('dragover', (event) => {
if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
})
dropZone.addEventListener('drop', (event) => {
event.preventDefault()
for (const file of event.dataTransfer.files) {
console.log(file.name, file.type, file.size)
}
})
A File object is data chosen by the user. Its name or MIME type is not proof that the contents are safe. Validate files again on the server before storing or processing them.
For a complete upload example, see file upload with drag and drop in vanilla JavaScript.
DataTransferItem and directories
dataTransfer.items describes both string and file items:
dropZone.addEventListener('drop', (event) => {
event.preventDefault()
for (const item of event.dataTransfer.items) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) console.log(file.name)
}
}
})
Directory traversal has historically depended on browser-specific APIs. If accepting folders matters, test the browsers you support and provide a normal file or directory picker as an alternative.
Native drag and drop is not enough for touch
Do not describe HTML drag and drop as a universal mouse-and-touch API.
Touch browsers have inconsistent support for dragging arbitrary page elements. Even where a gesture works, it can conflict with scrolling and long-press behavior.
For a custom touch sorter, Pointer Events can provide a unified pointer model. But that means you must implement hit testing, movement, cancellation, scrolling, and accessibility yourself.
For many interfaces, explicit Move Up and Move Down buttons are simpler and more reliable.
Do not confuse drag and drop with Pointer Events
The two approaches solve different problems.
HTML drag and drop transfers data. It can interact with browser tabs, other applications, files, selected text, and links. The browser owns much of the gesture and provides DataTransfer.
Pointer Events report mouse, pen, and touch input. They are a better foundation for moving a visual object continuously inside an application, such as a slider thumb, drawing tool, map marker, or game piece.
A pointer-based sorter must implement its own state:
- which pointer owns the drag
- where the item should be inserted
- when scrolling should start
- what happens on
pointercancel - how the item returns after an invalid drop
- how keyboard users perform the same operation
Pointer capture can keep events directed to the moving element, but it does not provide drag data or cross-application behavior.
Choose HTML drag and drop when data transfer is the core feature. Choose Pointer Events when direct manipulation is the core feature. A file drop zone needs HTML drag and drop. A touch-friendly canvas editor normally needs Pointer Events.
Do not run both gesture systems on the same handle without careful testing. They can compete for the same movement and make scrolling unpredictable.
Add a keyboard alternative
Dragging is a physical interaction, not the application’s only operation. Expose the same move through buttons:
<li>
<span>Write the guide</span>
<button type="button" data-move="up">Move up</button>
<button type="button" data-move="down">Move down</button>
</li>
tasks.addEventListener('click', (event) => {
const button = event.target.closest('[data-move]')
if (!button) return
const item = button.closest('li')
if (button.dataset.move === 'up' && item.previousElementSibling) {
tasks.insertBefore(item, item.previousElementSibling)
}
if (button.dataset.move === 'down' && item.nextElementSibling) {
tasks.insertBefore(item.nextElementSibling, item)
}
})
After moving, keep focus on the button and announce the new position in a polite live region. Do not rely on color or pointer movement to communicate the result.
Avoid global drop surprises
Dropping a file on a browser page can navigate away and open that file. If your application contains a file drop zone, prevent file drops outside it:
window.addEventListener('dragover', (event) => {
if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
}
})
window.addEventListener('drop', (event) => {
if (!event.target.closest('#file-drop')) {
event.preventDefault()
}
})
Scope this guard to files. Do not cancel every drag operation on the page without a reason.
Common mistakes
Most broken implementations come from a few details:
- forgetting
preventDefault()indragover - reading drag data before
drop - using
innerHTMLwith untrusted dropped HTML - moving the DOM but not updating application state
- leaving visual drag state behind after cancellation
- assuming
dragleavemeans the pointer left the whole nested target - providing no keyboard or touch alternative
- trusting file names and MIME types
Test cancellation, drops outside valid targets, nested targets, multiple files, and a keyboard-only path.
Test with zoom and horizontal scrolling too. A drag interaction that depends on tiny targets becomes difficult long before it technically stops working.
For a sortable list, persist the new order only after the move succeeds. If saving fails, either restore the old order or clearly show that the visible order is not saved. Dragging changes an interface quickly, which can hide an asynchronous failure.
When I would use it
I would use native drag and drop for a desktop-oriented board, a small list sorter, or a file drop zone. It integrates with files and data dragged from outside the page, which a custom pointer implementation cannot replace easily.
I would not make dragging the only way to complete an important task. Reordering should have buttons or another direct command. File upload should have a standard file input.
The HTML Standard drag-and-drop section defines the event model and the lifetime of the drag data store.
Related posts about platform: