Making Elements Draggable

1

Making Elements Draggable

▶

Drag-and-drop is one of the most intuitive interaction patterns available on the web. When implemented correctly, it allows users to pick up elements, carry them across the page, and deposit them in a new location — all with a natural, physical metaphor. The HTML Drag and Drop API is the browser-native mechanism that powers this behaviour, and the first step to using it is understanding how to make an element a drag source: an element that the user can grab and begin dragging. This involves three closely related concerns — declaring an element as draggable, identifying which element should be the drag source, and wiring up the event listeners that capture the start of a drag operation so data can be attached and visual feedback can be prepared.

The entire system builds on a sequence of DOM events (dragstart, drag, dragenter, dragover, dragleave, drop, dragend), but everything begins with the drag source and the dragstart event. Before any of those events fire, though, the browser must be told which elements are eligible to be dragged.

The draggable Attribute

The draggable attribute is a global HTML attribute that controls whether the browser treats an element as a drag source. It accepts exactly three string values: 'true', 'false', and 'auto'. Only these three values are valid; any other string is treated the same as 'auto'.

  • draggable="true" — Explicitly opts the element into the drag system. Once this is set, the browser will fire dragstart on the element when the user begins a drag gesture (typically a click-and-hold followed by movement on desktop, or a touch-and-hold on some mobile browsers).
  • draggable="false" — Explicitly opts the element out of dragging, even if the browser would otherwise make it draggable by default. This is useful on images and links (both of which are draggable by default in most browsers) when you want to suppress that behaviour.
  • draggable="auto" — Defers to the browser's default rules. In practice, <img> and <a href="..."> elements are draggable by default; almost everything else is not. When the attribute is absent entirely the behaviour is also 'auto'.

A simple example — making a paragraph draggable:

<p draggable="true" id="drag-me">
  Drag this paragraph to a drop zone.
</p>

And suppressing the default draggability of an image (for example, inside a photo gallery where you want to control drag behaviour yourself):

<img src="photo.jpg" draggable="false" alt="A landscape photo" />

It is worth noting that draggable is not a boolean attribute in the same way disabled or checked are. You cannot simply write <div draggable> and expect it to work — you must write the full string draggable="true". This is an easy mistake to make and a common source of confusion.

Identifying Drag Source Elements

Any visible DOM element can be designated as a drag source. The choice of which element you mark as draggable has real practical consequences for the user experience and for the logic of your drag-and-drop implementation.

Consider a task board where each card contains a heading, a description paragraph, a priority badge, and a delete button. If you add draggable="true" directly to the heading, only the heading is technically the drag source, which means the drag ghost image might only show the heading text, the hit area for initiating a drag is small, and it is harder to associate the card's full data with the operation. A better approach is to wrap the entire card in a container element — typically a <div> or an <li> — and mark that container as the drag source:

<ul id="task-list">
  <li class="task-card" draggable="true" data-task-id="42">
    <h3>Design the login screen</h3>
    <p>Create wireframes and high-fidelity mockups.</p>
    <span class="badge">High priority</span>
  </li>

  <li class="task-card" draggable="true" data-task-id="43">
    <h3>Write unit tests</h3>
    <p>Achieve 80% code coverage for the auth module.</p>
    <span class="badge">Medium priority</span>
  </li>
</ul>

Notice the use of a data-task-id custom data attribute on each <li>. This is a common pattern: store the record's identifier on the element itself so that the dragstart handler can read it and pass it to the drop target without having to search the DOM or rely on closure variables. It keeps the source of truth close to the element and makes the handler reusable across many cards.

Choosing a container as the drag source also gives you finer control over what moves. If the card contains interactive controls (buttons, input fields), those controls should generally not initiate a drag. You can prevent the drag from starting when the user clicks a button by calling event.preventDefault() on the mousedown or pointerdown event of those child controls, or by setting draggable="false" on them explicitly.

Setting Up the dragstart Event Listener

Marking an element as draggable="true" is necessary but not sufficient. To actually do anything useful with a drag operation, you must attach a dragstart event listener to the drag source (or to an ancestor using event delegation). This listener fires the moment the browser confirms that the user has started dragging the element.

const cards = document.querySelectorAll('.task-card');

cards.forEach(card => {
  card.addEventListener('dragstart', handleDragStart);
});

function handleDragStart(event) {
  // event.target is the element that was dragged
  const taskId = event.target.dataset.taskId;

  // Store data for the drop target to retrieve
  event.dataTransfer.setData('text/plain', taskId);

  // Signal that this is a move operation (not a copy)
  event.dataTransfer.effectAllowed = 'move';

  // Add a visual class so the card appears "lifted"
  event.target.classList.add('dragging');
}

For larger lists, event delegation is more efficient. Instead of attaching a listener to every card, you attach one listener to the parent list and check whether the drag originated from a card:

document.getElementById('task-list').addEventListener('dragstart', function(event) {
  const card = event.target.closest('.task-card');
  if (!card) return; // drag started on something else inside the list

  event.dataTransfer.setData('text/plain', card.dataset.taskId);
  event.dataTransfer.effectAllowed = 'move';
  card.classList.add('dragging');
});

The effectAllowed property is worth understanding in detail. It is a hint to both the browser and the drop target about what kind of operation is permitted:

Value Meaning
'none' Dropping is not permitted anywhere.
'copy' A copy of the source item may be made at the drop target.
'move' The item will be moved to the drop target (removed from the source).
'link' A link to the source will be created at the drop target.
'copyMove' Either copy or move is allowed.
'copyLink' Either copy or link is allowed.
'linkMove' Either link or move is allowed.
'all' All three operations are allowed.

The browser may change the cursor icon to reflect the effect — a plus symbol for copy, an arrow for move — though exact rendering varies between browsers and operating systems.

Storing Data with dataTransfer

The event.dataTransfer object is the communication channel between the drag source and the drop target. It acts as a temporary clipboard scoped to a single drag operation. The key method for writing data into it is setData(format, data).

The format parameter is a MIME type string. Common choices include:

  • 'text/plain' — Plain text. Universally supported. Good for IDs, simple strings, or content that may be dropped into native applications (such as a text editor).
  • 'text/html' — An HTML string. Useful when the drop target should insert markup.
  • 'text/uri-list' — A URL or list of URLs. Used when dragging links.
  • Custom keys — You can use any string as a format key (e.g., 'application/x-task-id' or even just 'task-id'), but custom formats will not be understood by external applications.

You can store multiple pieces of data under different formats in a single drag operation:

function handleDragStart(event) {
  const card = event.target.closest('.task-card');
  const taskId = card.dataset.taskId;

  // Store the ID as plain text (readable by external drop targets)
  event.dataTransfer.setData('text/plain', taskId);

  // Also store richer data as a JSON-encoded string under a custom key
  const taskData = {
    id: taskId,
    title: card.querySelector('h3').textContent,
    priority: card.querySelector('.badge').textContent
  };
  event.dataTransfer.setData('application/json', JSON.stringify(taskData));

  event.dataTransfer.effectAllowed = 'move';
}

The critical constraint is that dataTransfer only accepts strings. You cannot pass DOM nodes, File objects (for drag sources — files are handled differently on the drop side via dataTransfer.files), or plain JavaScript objects. If you need to transfer structured data, serialise it with JSON.stringify() in dragstart and deserialise it with JSON.parse() in the drop handler.

There is also a security constraint worth knowing: in some browsers, the data stored in dataTransfer is only readable inside the drop event handler of a valid drop target. During dragover and dragenter, you can read the list of available formats (via event.dataTransfer.types), but not the actual values. This protects sensitive data from being read by rogue drop targets on the same page.

Visual Feedback During Drag

When a drag begins, the browser automatically creates a "ghost image" — a semi-transparent copy of the dragged element that follows the cursor. While this default is often adequate, you have two primary tools for customising the visual feedback: setDragImage() and CSS class toggling.

Custom drag image with setDragImage()

The event.dataTransfer.setDragImage(element, xOffset, yOffset) method replaces the default ghost with any element. The element does not need to be visible in the document — it can be an off-screen element created purely for this purpose. The xOffset and yOffset values determine where, relative to the element's top-left corner, the cursor appears to "grip" it.

function handleDragStart(event) {
  // Create a custom ghost element
  const ghost = document.createElement('div');
  ghost.textContent = '📋 Moving task…';
  ghost.style.cssText = `
    position: absolute;
    top: -9999px;
    left: -9999px;
    background: #4a90e2;
    color: white;
    padding: 8px 16px;
    border-radius: 4px;
    font-size: 14px;
    white-space: nowrap;
  `;
  document.body.appendChild(ghost);

  // Use the ghost as the drag image; grip it at its top-left corner
  event.dataTransfer.setDragImage(ghost, 0, 0);

  // Clean up the ghost element after the drag ends
  event.target.addEventListener('dragend', () => {
    document.body.removeChild(ghost);
  }, { once: true });

  event.dataTransfer.setData('text/plain', event.target.dataset.taskId);
  event.dataTransfer.effectAllowed = 'move';
}

The element passed to setDragImage() must be in the DOM at the time the call is made (the browser reads it synchronously during the event), which is why the pattern above appends it to document.body first, positioned off-screen to hide it from the visible layout.

CSS class toggling for source styling

A common and effective pattern is to apply a CSS class to the drag source in dragstart to visually indicate that it is "in flight" — typically by reducing its opacity or applying a dashed border to mark where it came from:

/* CSS */
.task-card.dragging {
  opacity: 0.4;
  border: 2px dashed #4a90e2;
}
// JavaScript
function handleDragStart(event) {
  event.target.classList.add('dragging');
  event.dataTransfer.setData('text/plain', event.target.dataset.taskId);
  event.dataTransfer.effectAllowed = 'move';
}

However, there is an important subtlety here. In some browsers, if you apply a CSS change to the drag source synchronously inside the dragstart handler, that change is reflected in the ghost image (because the ghost is captured at that moment). To avoid this, wrap the class addition in a setTimeout with a delay of 0 so it executes after the ghost image is captured:

function handleDragStart(event) {
  event.dataTransfer.setData('text/plain', event.target.dataset.taskId);
  event.dataTransfer.effectAllowed = 'move';

  const card = event.target;
  setTimeout(() => card.classList.add('dragging'), 0);
}

To restore the element's appearance after the drag operation ends (whether or not it resulted in a successful drop), use the dragend event. This event fires on the drag source element, not the drop target, which makes it the ideal place to clean up:

function handleDragEnd(event) {
  event.target.classList.remove('dragging');
}

cards.forEach(card => {
  card.addEventListener('dragstart', handleDragStart);
  card.addEventListener('dragend', handleDragEnd);
});

Accessibility Considerations for Draggable Elements

Drag-and-drop presents real barriers for users who rely on keyboard navigation, switch access devices, or screen readers. The HTML Drag and Drop API is fundamentally pointer-based and is not natively keyboard-accessible. Addressing this responsibly involves both ARIA attributes and a parallel keyboard-friendly interaction pattern.

ARIA attributes

The aria-grabbed attribute (part of the older ARIA 1.0 specification) was intended to communicate the grabbed state of a draggable element to assistive technologies. While it has been deprecated in ARIA 1.1 (the current recommendation is to avoid it and use other patterns), you may still encounter it in legacy code. A more robust approach is to use aria-roledescription and live regions:

<li
  class="task-card"
  draggable="true"
  data-task-id="42"
  tabindex="0"
  role="option"
  aria-roledescription="draggable item"
  aria-describedby="drag-instructions"
>
  Design the login screen
</li>

<p id="drag-instructions" class="sr-only">
  Press Space to pick up, use arrow keys to move, press Space again to drop,
  or press Escape to cancel.
</p>

Keyboard focusability

Set tabindex="0" on every draggable element so that keyboard users can focus it with the Tab key. Without this, elements that are not natively focusable (such as <div> and <li>) are invisible to keyboard navigation.

<li class="task-card" draggable="true" tabindex="0" data-task-id="42">
  Design the login screen
</li>

Providing a non-drag alternative

The single most important accessibility rule for drag-and-drop is this: never make drag-and-drop the only way to accomplish a task. Always provide an alternative. Common alternatives include:

  • A keyboard-driven "pick up and place" mode triggered by pressing Space or Enter, then using arrow keys to move the item, and pressing Space or Enter again to drop it.
  • A context menu or action button (e.g., "Move to column…") that opens a dialog allowing the user to select a target location.
  • Reorder buttons (up/down arrows) adjacent to each item in an ordered list.

Below is a minimal example combining all the draggable setup concepts — the draggable attribute, keyboard focusability, dragstart listener, dataTransfer usage, CSS class feedback, and dragend cleanup — into a single coherent pattern:

<!-- HTML -->
<ul id="task-list" aria-label="Task cards">
  <li class="task-card" draggable="true" tabindex="0"
      data-task-id="1" role="option"
      aria-roledescription="draggable task">
    Write the project brief
  </li>
  <li class="task-card" draggable="true" tabindex="0"
      data-task-id="2" role="option"
      aria-roledescription="draggable task">
    Review the design mockups
  </li>
</ul>
/* CSS */
.task-card {
  padding: 12px 16px;
  margin: 8px 0;
  background: #fff;
  border: 1px solid #ddd;
  border-radius: 6px;
  cursor: grab;
  list-style: none;
  transition: opacity 0.2s, border-color 0.2s;
}

.task-card:focus {
  outline: 3px solid #4a90e2;
  outline-offset: 2px;
}

.task-card.dragging {
  opacity: 0.4;
  border: 2px dashed #4a90e2;
  cursor: grabbing;
}
// JavaScript
const list = document.getElementById('task-list');

list.addEventListener('dragstart', function(event) {
  const card = event.target.closest('.task-card');
  if (!card) return;

  const taskId = card.dataset.taskId;

  event.dataTransfer.setData('text/plain', taskId);
  event.dataTransfer.setData('application/json', JSON.stringify({ id: taskId }));
  event.dataTransfer.effectAllowed = 'move';

  // Defer the class addition so the ghost image is not affected
  setTimeout(() => card.classList.add('dragging'), 0);
});

list.addEventListener('dragend', function(event) {
  const card = event.target.closest('.task-card');
  if (card) card.classList.remove('dragging');
});

This pattern is extensible: once the drag source side is set up cleanly, wiring in the drop target (using dragover, dragenter, dragleave, and drop listeners) becomes straightforward because the data has already been stored in dataTransfer and is ready to be retrieved.

NotesThe setTimeout trick for deferring class application during dragstart is widely needed in Chrome and Safari; worth calling out clearly. The aria-grabbed deprecation note is accurate as of ARIA 1.1 — learners should be pointed toward live-region announcements and custom keyboard handlers as the current best practice rather than aria-grabbed.