The definitive guide to drag and drop in JavaScript

By

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:

  • dragstart when dragging begins
  • drag repeatedly while dragging continues
  • dragend when the operation finishes or is canceled

Potential targets receive:

  • dragenter when the pointer enters
  • dragover repeatedly while it remains over the target
  • dragleave when it leaves
  • drop when 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() and getData() for strings
  • types for the available formats
  • items for drag data items
  • files for dropped files
  • effectAllowed for operations allowed by the source
  • dropEffect for 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.

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() in dragover
  • reading drag data before drop
  • using innerHTML with untrusted dropped HTML
  • moving the DOM but not updating application state
  • leaving visual drag state behind after cancellation
  • assuming dragleave means 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: