How to Implement Drag and Drop in JavaScript
Build native JavaScript drag and drop with validated data transfer, accessible alternatives, troubleshooting, and a complete sortable-list example.
To implement native drag and drop in JavaScript, mark eligible source elements with draggable="true", write a stable identifier to event.dataTransfer in dragstart, cancel dragover on valid destinations, then validate the payload and update your application state in drop. Use dragend for cleanup. For reordering, render the list from state and provide a single-pointer alternative such as Move up and Move down buttons.
This guide builds a runnable sortable list, explains the native event lifecycle and data transfer, covers accessibility and edge cases, and shows how to inspect a page with ScreenshotNeo after implementing the interaction.
1. A complete sortable-list example
Save this as index.html and open it in a browser. Drag a task to another position, or use the buttons to reorder without dragging. The example uses a private data type, validates the transferred ID against current state, and updates the list from that state.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sortable tasks</title>
<style>
body { font: 1rem/1.5 system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; }
ul { list-style: none; padding: 0; }
li { display: flex; align-items: center; gap: .75rem; padding: .75rem; border: 1px solid #bbb; margin-block: .5rem; border-radius: .4rem; }
li[aria-grabbed="true"] { opacity: .55; }
li.is-over { outline: 3px solid #2864dc; }
.label { flex: 1; }
button { font: inherit; }
#status { min-height: 1.5em; }
</style>
</head>
<body>
<h1>Release tasks</h1>
<p>Drag a task to reorder it, or use its Move up and Move down buttons.</p>
<ul id="tasks" aria-label="Release tasks"></ul>
<p id="status" role="status" aria-live="polite"></p>
<script>
const tasks = [
{ id: 'task-42', label: 'Prepare release notes' },
{ id: 'task-43', label: 'Review open issues' },
{ id: 'task-44', label: 'Publish the release' }
];
const list = document.querySelector('#tasks');
const status = document.querySelector('#status');
const transferType = 'application/x-example-task-id';
let draggedId = null;
function render() {
list.replaceChildren(...tasks.map((task, index) => {
const li = document.createElement('li');
li.draggable = true;
li.dataset.id = task.id;
li.setAttribute('aria-grabbed', 'false');
const label = document.createElement('span');
label.className = 'label';
label.textContent = task.label;
const up = document.createElement('button');
up.type = 'button';
up.textContent = 'Move up';
up.setAttribute('aria-label', `Move ${task.label} up`);
up.disabled = index === 0;
up.addEventListener('click', () => moveBy(task.id, -1));
const down = document.createElement('button');
down.type = 'button';
down.textContent = 'Move down';
down.setAttribute('aria-label', `Move ${task.label} down`);
down.disabled = index === tasks.length - 1;
down.addEventListener('click', () => moveBy(task.id, 1));
li.append(label, up, down);
return li;
}));
}
function moveBy(id, offset) {
const from = tasks.findIndex(task => task.id === id);
const to = from + offset;
if (from < 0 || to < 0 || to >= tasks.length) return;
const [task] = tasks.splice(from, 1);
tasks.splice(to, 0, task);
render();
list.children[to].querySelector('button').focus();
status.textContent = `${task.label} moved to position ${to + 1} of ${tasks.length}.`;
}
function moveBefore(sourceId, targetId) {
if (sourceId === targetId) return false;
const from = tasks.findIndex(task => task.id === sourceId);
const to = tasks.findIndex(task => task.id === targetId);
if (from < 0 || to < 0) return false;
const [task] = tasks.splice(from, 1);
const adjustedTo = tasks.findIndex(item => item.id === targetId);
tasks.splice(adjustedTo, 0, task);
render();
status.textContent = `${task.label} moved to position ${adjustedTo + 1} of ${tasks.length}.`;
return true;
}
list.addEventListener('dragstart', event => {
const row = event.target.closest('li[data-id]');
if (!row || !list.contains(row)) return;
draggedId = row.dataset.id;
row.setAttribute('aria-grabbed', 'true');
event.dataTransfer.setData(transferType, draggedId);
event.dataTransfer.effectAllowed = 'move';
});
list.addEventListener('dragover', event => {
const row = event.target.closest('li[data-id]');
if (!row || !list.contains(row) || !draggedId || row.dataset.id === draggedId) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
row.classList.add('is-over');
});
list.addEventListener('dragleave', event => {
const row = event.target.closest('li[data-id]');
if (row && !row.contains(event.relatedTarget)) row.classList.remove('is-over');
});
list.addEventListener('drop', event => {
const row = event.target.closest('li[data-id]');
if (!row || !list.contains(row)) return;
event.preventDefault();
const id = event.dataTransfer.getData(transferType);
row.classList.remove('is-over');
if (!tasks.some(task => task.id === id) || !tasks.some(task => task.id === row.dataset.id)) return;
moveBefore(id, row.dataset.id);
});
list.addEventListener('dragend', event => {
list.querySelectorAll('.is-over').forEach(row => row.classList.remove('is-over'));
const row = event.target.closest('li[data-id]');
if (row) row.setAttribute('aria-grabbed', 'false');
draggedId = null;
});
render();
</script>
</body>
</html>
In a production app, replace the in-memory array operations with your state-management or persistence layer. Keep the payload to an ID and resolve it against the latest state at drop time. The handler must also check permissions and destination rules before committing a move.
2. Native drag and drop event lifecycle
Native HTML drag and drop is an event-based interaction. The standard lifecycle is typically:
dragstarton the source: set the transferable data and allowed operation.dragenteranddragleaveas the pointer crosses elements: update optional visual feedback.dragoverrepeatedly on the current target: cancel the event to accept a drop and set itsdropEffect.dropon the chosen target: read, validate, and use the payload.dragendon the source: clear temporary state whether the drag succeeded or was canceled.
Most elements do not accept drops by default. Calling event.preventDefault() during dragover signals that the target accepts the operation. The specification also calls for canceling dragenter, but browsers do not implement that requirement in practice. During a native drag, ordinary device input events are suppressed, so mouse or keyboard handlers do not replace the drag event lifecycle. See the WHATWG drag and drop standard and MDN’s event walkthrough.
3. Minimal pattern for moving an item between targets
This smaller example shows the core contract. It transfers only a task ID, accepts the drop on one destination, validates the ID, and leaves the state update to your application.
<ul id="source-list">
<li draggable="true" data-id="task-42">Prepare release notes</li>
</ul>
<div id="drop-zone">Drop task here</div>
<script>
const item = document.querySelector('[draggable="true"]');
const dropZone = document.querySelector('#drop-zone');
const type = 'application/x-example-task-id';
item.addEventListener('dragstart', event => {
event.dataTransfer.setData(type, item.dataset.id);
event.dataTransfer.effectAllowed = 'move';
});
dropZone.addEventListener('dragover', event => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
});
dropZone.addEventListener('drop', event => {
event.preventDefault();
const id = event.dataTransfer.getData(type);
if (id !== 'task-42') return;
// Look up the item in current application state, validate this destination,
// then update state and render the result.
});
</script>
4. DataTransfer, allowed effects, and validation
DataTransfer is the browser’s transport for drag data. It supports text and files; it does not share arbitrary JavaScript object references with another page or application. Serialize needed data as a string or, preferably for in-app operations, transfer a stable identifier and retrieve the authoritative record from current state. MDN documents the HTML Drag and Drop API.
- Choose a data type: use a type such as
application/x-example-task-idfor app-specific data ortext/plainfor interoperable text. Read the same type on drop. - Set allowed operation: set
dataTransfer.effectAllowedat drag start tomove,copy,copyMove, or another supported effect that matches the interaction. - Set the target effect: set
dataTransfer.dropEffectduring an accepteddragover. This controls feedback such as the cursor operation indicator; it does not perform the move. - Validate at drop: reject missing, unknown, stale, malformed, unauthorized, or wrong-type values. Treat all transferred strings as input, especially when data might arrive from outside your page.
- Update state: a successful browser drop does not move your application data or persist anything automatically. Update the model, then render from it.
For external files, inspect event.dataTransfer.files in drop, prevent the browser’s default navigation behavior where appropriate, and validate file type and size in your application. A page receiving text or files from outside should not assume the payload came from one of its own elements.
5. Reordering and choosing a drop position
The full example inserts an item before the row on which it is dropped. For a board, resolve the destination column and insertion index from the target, then update the model with both the new container and position. Define behavior for dropping on the same item, dropping into an empty container, and dropping at the end of a list. Avoid using DOM mutation alone as the source of truth when a framework or state store renders the interface.
For large lists, do not rebuild every row on every dragover. The event can fire repeatedly while the pointer stays over a target. Keep the handler light: identify the target, apply or remove feedback classes, and defer the actual model update until drop. Use event delegation on a stable parent where it simplifies dynamically created items.
6. Accessibility: offer a way to move without dragging
Dragging must not be the only way to operate the feature. WCAG 2.2 Success Criterion 2.5.7 requires that functionality using a dragging movement can also be achieved by a single pointer without dragging, unless an exception applies. Keyboard access is a separate need: keyboard-only support does not by itself provide the required single-pointer alternative. Read WCAG 2.2 and the W3C’s explanation of Dragging Movements.
Practical alternatives for reordering include Move up and Move down buttons, or selecting an item and then selecting a destination. WAI’s G219 technique describes stepwise controls and destination selection as approaches. Make the action and result understandable to assistive technology, announce moves where helpful, and leave focus in a useful place after re-rendering.
- Make alternative controls visible and operable with a single click or tap.
- Give each control a clear accessible name that identifies both the item and action.
- Disable or omit actions that cannot apply, such as Move up on the first row.
- After a move, preserve or deliberately restore focus; do not send it to the page start.
- Provide status feedback about the new position when the visual change may not be obvious.
- Test with keyboard navigation and assistive technology as well as a pointer.
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| The drop handler never runs. | The destination did not cancel dragover, or the pointer was not over the target. |
Call preventDefault() on valid targets in dragover. Check target selection and event delegation. |
| The cursor indicates a forbidden drop. | dropEffect and effectAllowed conflict, or the target has not opted in. |
Set a compatible allowed effect at drag start and drop effect during accepted dragover. |
getData() returns an empty string. |
The type does not match, data was not set in dragstart, or code reads it outside the appropriate event. |
Set and retrieve the exact same type; read the payload in drop; handle empty data as invalid. |
| The item appears moved but snaps back after rendering. | The DOM was changed without updating the state used by the renderer. | Update the source model or store and render from that state. |
| A stale item ID causes the wrong record to move. | The handler trusts a payload without checking current records or permissions. | Resolve the ID against current state and validate source, destination, and authorization before mutation. |
| Rows flicker or the target highlight disappears. | dragleave fires while crossing a child inside the same row. |
Only clear the row state when relatedTarget is outside that row, or track the current target centrally. |
| Drag works with a mouse but not as expected on touch. | Native HTML DnD behavior and custom pointer interactions are not interchangeable across input modes. | Test on intended devices. For touch-first interactions, assess a pointer-driven implementation while retaining keyboard and non-drag alternatives. |
| Users can drag but cannot operate the feature without dragging. | The interaction lacks the WCAG single-pointer alternative. | Add move controls or a select-then-destination flow; keyboard support alone does not meet this separate requirement. |
8. Performance, reliability, and cost considerations
Native drag events are suitable when the browser interaction matches the task and you want the browser to manage the drag lifecycle. Keep repeated dragover work small, avoid expensive layout reads or full-list renders in that handler, and commit state once on drop. For remote persistence, update optimistically only if your application can recover cleanly from a rejected save; otherwise wait for the server and announce failure clearly.
Handle canceled drags and drops outside valid targets in dragend, since it runs whether the drag succeeded or was canceled. Always clear highlight and temporary state there. Validate at the trust boundary before changing data. Native browser events have no service fee; costs arise from any application services, persistence, or third-party interaction library you choose. The research sources establish the native API behavior and accessibility criteria, but do not provide comparative library benchmarks.
9. Or skip the browser setup
If you are documenting or reviewing the interaction on a live page, ScreenshotNeo can capture a page with one GET request. See the ScreenshotNeo API documentation for its screenshot and PDF options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
ScreenshotNeo accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. Its MCP server lets Claude, Cursor, and other MCP clients use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. ScreenshotNeo is a website screenshot API and MCP server by Yorker Media; see ScreenshotNeo for details.
Sign up for 1,000 free screenshots a month, with no card required.
10. Frequently asked questions
Can I drag any HTML element?
Elements are not draggable by default in the same way as an element with draggable="true". Add the attribute to the elements that should act as sources.
Does dropping an element automatically move it in the DOM?
No. The browser reports the drop and exposes transferred data; your code must update application state or deliberately change the DOM.
Does keyboard support satisfy WCAG’s dragging alternative?
No. WCAG 2.2 SC 2.5.7 separately calls for a single-pointer method that does not require dragging, unless an exception applies.
Should I use native drag and drop for every sortable interface?
No single approach fits every input mode or design. Choose based on your target devices, required pointer behavior, state integration, feedback needs, keyboard support, and non-drag alternative.


