Skip to main content

🎯 Drag and Drop API

Grab something, move it, let go — it's the most natural interaction there is. The HTML5 Drag and Drop API brings that directness to the browser: sortable lists, kanban boards, and "drop files here" upload zones all rest on the same handful of events. This lesson demystifies them.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Make elements draggable and trace the seven drag events from start to end
  • Create valid drop zones — and explain why preventDefault is mandatory
  • Move data with the dataTransfer object and choose the right effect
  • Accept files dragged from the desktop and read their metadata
  • Build an accessible sortable list and know when a library is the better call

Estimated Time: 40–50 minutes  •  Difficulty: Intermediate

Hands-on: Build a two-column drag-and-drop board that moves items between lists.

In This Lesson

Direct Manipulation on the Web

The HTML5 Drag and Drop (DnD) API lets users grab an on-screen element and move it somewhere else, mirroring how we shuffle papers on a desk. Because the gesture matches a physical intuition, drag-and-drop interfaces feel obvious with almost no instruction.

You'll find it powering:

  • File uploads — dragging files from the desktop into the page
  • Kanban boards — moving cards between "To Do" and "Done"
  • Sortable lists — reordering items by priority
  • Builders and editors — arranging blocks, images, or form fields

⚠️ One big caveat up front

The native DnD API does not fire on touch screens the way it does with a mouse. For phone and tablet support you'll either add Pointer Events yourself or use a library (covered at the end). Keep that in mind before shipping DnD as your only way to do something.

The Seven Drag Events

A drag is a sequence of events split between the source (what you're dragging) and the target (where you might drop it):

Drag events on source versus target The drag source fires dragstart, drag, and dragend; the drop target fires dragenter, dragover, dragleave, and drop as the item passes over it. Drag source dragstart — you grab it drag — while moving dragend — you let go Drop target dragenter — item arrives dragover — hovering dragleave — item exits drop — released here drag across
Figure 1 — Source events and target events. The critical pair for enabling a drop is dragover and drop.
flowchart LR A[dragstart] --> B[drag] B --> C[dragenter] C --> D[dragover] D --> E[drop] E --> F[dragend] D --> G[dragleave]

Making Elements Draggable

Any element becomes draggable with one attribute:

<div id="card" draggable="true">Drag me!</div>

Images, links, and selected text are draggable by default. To make the drag mean something, handle dragstart — that's where you stash the data being moved and set the allowed effect:

const card = document.getElementById('card');

card.addEventListener('dragstart', (event) => {
  // Attach the payload — here, the element's id
  event.dataTransfer.setData('text/plain', event.target.id);
  event.dataTransfer.effectAllowed = 'move';
  card.classList.add('dragging'); // for CSS feedback
});

card.addEventListener('dragend', () => {
  card.classList.remove('dragging'); // always clean up
});

💡 Style the drag state in CSS

Add a class in dragstart and remove it in dragend, then let CSS do the visuals — e.g. .dragging { opacity: .5; }. Doing feedback in CSS instead of JavaScript keeps the drag smooth.

Creating Drop Zones

Here's the part that surprises everyone: by default the browser refuses drops. To turn an element into a valid drop zone you must call event.preventDefault() in its dragover handler. Skip that one line and drop never fires.

const zone = document.getElementById('drop-zone');

// preventDefault here is what makes dropping possible
zone.addEventListener('dragover', (event) => {
  event.preventDefault();
  event.dataTransfer.dropEffect = 'move';
  zone.classList.add('drag-over');
});

zone.addEventListener('dragleave', () => {
  zone.classList.remove('drag-over');
});

zone.addEventListener('drop', (event) => {
  event.preventDefault();               // stop the browser's default (e.g. opening a link)
  zone.classList.remove('drag-over');
  const id = event.dataTransfer.getData('text/plain');
  const dragged = document.getElementById(id);
  zone.appendChild(dragged);            // move the element into the zone
});
💡 Why the double preventDefault? Think of the browser as having a "No Entry" sign on every element. preventDefault in dragover removes the sign so a drop is allowed; preventDefault in drop stops the browser's built-in behaviour (like navigating to a dropped link) so your code runs instead.

The dataTransfer Object

Every drag event carries a dataTransfer object — the courier that holds the payload and controls the visual effect.

MemberWhat it does
setData(format, data)Store the payload under a MIME type
getData(format)Read it back in the drop handler
setDragImage(el, x, y)Use a custom image as the drag preview
effectAllowedSet in dragstart — which effects are permitted
dropEffectSet in dragover — the cursor shown (copy / move / link)
filesA FileList when the user drops files from the OS

You can store several formats at once, letting different targets read what they understand:

event.dataTransfer.setData('text/plain', 'Buy milk');
event.dataTransfer.setData('application/json',
  JSON.stringify({ id: 123, type: 'task' }));

📖 effectAllowed vs dropEffect

effectAllowed (source, in dragstart) declares the menu of options — 'copy', 'move', 'copyMove', etc. dropEffect (target, in dragover) picks the single one currently in play, which decides the cursor icon the user sees.

Dropping Files

The killer feature: users can drag files straight from their file manager into your page. Dropped files land in event.dataTransfer.files as a FileList.

const zone = document.getElementById('file-zone');

// Prevent the browser from just opening the file
['dragover', 'drop'].forEach((name) =>
  zone.addEventListener(name, (e) => e.preventDefault()));

zone.addEventListener('drop', (event) => {
  const files = [...event.dataTransfer.files]; // FileList -> Array
  for (const file of files) {
    console.log(`${file.name} — ${formatSize(file.size)} — ${file.type}`);
    // In a real app: upload with FormData + fetch
    // const body = new FormData();
    // body.append('file', file);
    // await fetch('/upload', { method: 'POST', body });
  }
});

function formatSize(bytes) {
  if (bytes === 0) return '0 B';
  const units = ['B', 'KB', 'MB', 'GB'];
  const i = Math.floor(Math.log(bytes) / Math.log(1024));
  return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`;
}

✅ Always pair it with a normal file input

Drag-to-upload is a delight, but keep a plain <input type="file"> too — it's the accessible, touch-friendly, keyboard-friendly path. Drag and drop is the enhancement, not the only door in.

Hands-on: Two-Column Board

🏋️ Move tasks between "To Do" and "Done"

Objective: Build two columns and let the user drag task cards from one to the other — the foundation of every kanban board.

Starter HTML

<div class="board">
  <ul class="column" id="todo">
    <li class="task" draggable="true" id="t1">Write tests</li>
    <li class="task" draggable="true" id="t2">Fix login bug</li>
  </ul>
  <ul class="column" id="done"></ul>
</div>

Your tasks

  1. On each task's dragstart, store its id in dataTransfer.
  2. On each column's dragover, call preventDefault() so drops are allowed.
  3. On drop, read the id and appendChild the task into that column.
  4. Bonus: add a .drag-over highlight class on the column while an item hovers.
💡 Hint

Attach the task listeners with a loop over document.querySelectorAll('.task'), and the column listeners over document.querySelectorAll('.column'). In drop, event.currentTarget is the column you dropped onto.

✅ Solution
document.querySelectorAll('.task').forEach((task) => {
  task.addEventListener('dragstart', (e) => {
    e.dataTransfer.setData('text/plain', task.id);
    e.dataTransfer.effectAllowed = 'move';
    task.classList.add('dragging');
  });
  task.addEventListener('dragend', () => task.classList.remove('dragging'));
});

document.querySelectorAll('.column').forEach((col) => {
  col.addEventListener('dragover', (e) => {
    e.preventDefault();               // enable dropping
    col.classList.add('drag-over');
  });
  col.addEventListener('dragleave', () => col.classList.remove('drag-over'));
  col.addEventListener('drop', (e) => {
    e.preventDefault();
    col.classList.remove('drag-over');
    const id = e.dataTransfer.getData('text/plain');
    col.appendChild(document.getElementById(id));
  });
});

That's a working board in ~20 lines. Persisting the layout is just a matter of saving each column's task ids to localStorage on every drop — tying this lesson back to the Web Storage one.

Accessibility & Libraries

Drag and drop is mouse-shaped by nature, which makes it a common accessibility trap. A keyboard-only or touch user must be able to accomplish the same thing another way.

✅ Do

  • Provide a keyboard alternative — e.g. "Move up / Move down" buttons on each item.
  • Show clear visual feedback: highlight valid drop zones and the item being dragged.
  • Announce changes to screen readers with an aria-live region.
  • Always keep a non-drag path (buttons, a file input) to the same outcome.

🚫 Don't

  • Make drag the only way to perform an action.
  • Forget to remove drag styling in dragend — orphaned classes linger.
  • Do heavy work in dragover; it fires many times per second.
  • Assume it works on touch — test on real devices.

When to reach for a library

The native API is fine for simple cases, but production apps often want touch support, smooth animations, and accessibility baked in. Popular choices:

LibraryBest for
SortableJSFramework-agnostic sortable lists with touch support
@dnd-kitModern, accessible drag and drop for React
interact.jsDragging, resizing, snapping, and gestures

With SortableJS the earlier board becomes almost nothing:

import Sortable from 'sortablejs';

new Sortable(document.getElementById('todo'), {
  group: 'board',      // shared group lets items move between lists
  animation: 150,
  onEnd: (evt) => console.log('moved to index', evt.newIndex)
});

Summary & Quiz

🎉 Key Takeaways

  • Set draggable="true", then handle dragstart (store data) and dragend (clean up) on the source.
  • A drop zone needs preventDefault() in both dragover and drop — without it, nothing drops.
  • The dataTransfer object carries the payload and controls the copy/move/link effect.
  • Dropped files arrive in dataTransfer.files as a FileList.
  • Native DnD isn't touch- or keyboard-friendly — always provide an alternative, or use a library.

🎯 Quick Quiz

Question 1: Your drop handler never runs, no matter where you release the item. What's the most likely cause?

Question 2: Where do files land when a user drags them from their desktop onto a drop zone?

Question 3: Which is the strongest accessibility practice for a drag-and-drop feature?

📚 Further Reading

🚀 What's Next?

You've now handled pointers, files, and direct manipulation. Next we shift from moving elements to drawing them: the fundamentals of SVG, where shapes are described in markup and scale crisply to any size.

🎉 Nice work!

Sortable lists, kanban boards, and drop-to-upload are all within reach now — just remember the alternative path.