Skip to main content

How to Implement Drag and Drop with Mouse Events in JavaScript

Drag and drop is one of the most satisfying interactions you can build on the web. From sortable lists to file uploaders, kanban boards to custom sliders, drag and drop is everywhere. While the browser provides a native HTML Drag and Drop API, understanding how to build drag and drop from scratch using basic mouse events gives you full control over the behavior and visual feedback.

This guide walks you through the core algorithm for drag and drop using mousedown, mousemove, and mouseup, explains how to position elements correctly during a drag, how to detect drop targets, and when to use the built-in HTML Drag and Drop API instead.

The Basic Drag Algorithm

Every drag and drop interaction follows the same fundamental sequence:

  1. The user presses the mouse button on a draggable element (mousedown).
  2. The user moves the mouse while holding the button (mousemove).
  3. The element follows the cursor.
  4. The user releases the mouse button (mouseup).
  5. The element is "dropped" at the new position.

In pseudocode, the algorithm looks like this:

on mousedown:
- remember that dragging has started
- prepare the element for dragging

on mousemove:
- if dragging, move the element to follow the cursor

on mouseup:
- stop dragging
- handle the drop

The key insight is that mousemove and mouseup listeners must be attached to the document, not to the dragged element. If you attach them to the element itself, fast mouse movement can cause the cursor to leave the element, breaking the drag.

mousedown, mousemove, mouseup: Building a Draggable Element

Let's build a draggable element step by step, starting with the simplest possible version and fixing problems as we encounter them.

First Attempt (Naive)

<style>
#draggable {
width: 100px;
height: 100px;
background: coral;
position: absolute;
left: 50px;
top: 50px;
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
user-select: none;
font-weight: bold;
color: white;
}
</style>

<div id="draggable">Drag me</div>

<script>
const box = document.getElementById('draggable');

box.addEventListener('mousedown', (event) => {
// Move the element on mousemove
document.addEventListener('mousemove', onMouseMove);

// Drop the element on mouseup
document.addEventListener('mouseup', onMouseUp);
});

function onMouseMove(event) {
box.style.left = event.pageX + 'px';
box.style.top = event.pageY + 'px';
}

function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
</script>

This works, but there is a visible problem: the element jumps. When you click somewhere in the middle of the box, the top-left corner instantly snaps to the cursor position. That is because we are setting left and top directly to the cursor coordinates without accounting for where inside the element the user clicked.

The Browser's Built-In Drag Interference

Before fixing the jump, there is another issue. Browsers have a native drag and drop behavior for images and some other elements. If your draggable element contains an image or selected text, the browser might start its own drag operation, interfering with yours.

To prevent this, call preventDefault() on the mousedown event:

box.addEventListener('mousedown', (event) => {
event.preventDefault(); // Prevent native drag behavior

document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});

You should also add user-select: none in CSS to prevent text selection during dragging, as shown in the styles above.

warning

Always call event.preventDefault() in your mousedown handler for draggable elements. Without it, the browser's native drag behavior can hijack your custom drag logic, especially on images and links.

Correct Positioning with shiftX and shiftY

The jump happens because we are ignoring the offset between the cursor and the element's top-left corner. When the user clicks in the center of a 100x100 box, the cursor might be at coordinates (100, 100) inside the element. Setting left and top to the cursor's page coordinates places the corner at the cursor, not the point where the user grabbed the element.

The fix is to calculate the shift between the cursor and the element's top-left corner at the moment of mousedown, and then subtract that shift during every mousemove.

const box = document.getElementById('draggable');

box.addEventListener('mousedown', (event) => {
event.preventDefault();

// Calculate the shift: distance from the element's corner to the click point
const shiftX = event.clientX - box.getBoundingClientRect().left;
const shiftY = event.clientY - box.getBoundingClientRect().top;

function onMouseMove(event) {
box.style.left = event.pageX - shiftX + 'px';
box.style.top = event.pageY - shiftY + 'px';
}

function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}

document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});

Now the element stays exactly where the user grabbed it. The cursor maintains its position relative to the element throughout the drag.

Why clientX for the Shift but pageX for Positioning

Notice that we use event.clientX with getBoundingClientRect() to compute the shift, but event.pageX to set the position.

  • getBoundingClientRect() returns coordinates relative to the viewport (window), which aligns with clientX/clientY.
  • The element's left/top styles (with position: absolute) are relative to the document (or the nearest positioned ancestor), which aligns with pageX/pageY.

If the page is not scrolled, clientX and pageX are the same. But on a scrolled page, pageX = clientX + scrollX. Using the wrong pair will cause the element to jump when the page is scrolled.

tip

When calculating the shift on mousedown, match coordinate systems: clientX with getBoundingClientRect(). When setting position on mousemove, use pageX/pageY for position: absolute elements relative to the document.

Complete Working Example

Here is the full, corrected draggable element:

<style>
.draggable {
position: absolute;
width: 120px;
height: 120px;
border-radius: 10px;
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: white;
user-select: none;
}
.draggable.dragging {
cursor: grabbing;
opacity: 0.8;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
}
#box1 { background: #e74c3c; left: 50px; top: 80px; }
#box2 { background: #3498db; left: 200px; top: 80px; }
</style>

<div id="box1" class="draggable">Red</div>
<div id="box2" class="draggable">Blue</div>

<script>
document.querySelectorAll('.draggable').forEach(box => {
box.addEventListener('mousedown', (event) => {
event.preventDefault();

const shiftX = event.clientX - box.getBoundingClientRect().left;
const shiftY = event.clientY - box.getBoundingClientRect().top;

box.classList.add('dragging');

// Bring to front
box.style.zIndex = 1000;

function onMouseMove(event) {
box.style.left = event.pageX - shiftX + 'px';
box.style.top = event.pageY - shiftY + 'px';
}

function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
box.classList.remove('dragging');
box.style.zIndex = '';
}

document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
});
</script>

This version includes visual feedback (opacity change, shadow, cursor change) and supports multiple draggable elements independently.

Potential Drop Targets: The elementFromPoint Technique

Moving an element around is only half of drag and drop. The other half is detecting where the element is dropped. You need to know which element is underneath the dragged item when the user releases the mouse.

The Problem: The Dragged Element Is in the Way

The obvious approach would be to check event.target on mouseup. But there is a problem: the element being dragged is directly under the cursor, so event.target will always be the dragged element itself, not the drop zone underneath it.

document.addEventListener('mouseup', (event) => {
console.log(event.target); // Always the dragged element, not the drop zone!
});

The Solution: document.elementFromPoint()

The document.elementFromPoint(x, y) method returns the topmost element at the given coordinates. The trick is to temporarily hide the dragged element, call elementFromPoint(), and then show it again. This happens so fast that the user never sees the flicker.

function onMouseUp(event) {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);

// Temporarily hide the dragged element
box.style.display = 'none';

// Find what's underneath
const elemBelow = document.elementFromPoint(event.clientX, event.clientY);

// Show the dragged element again
box.style.display = '';

if (elemBelow) {
const dropZone = elemBelow.closest('.drop-zone');
if (dropZone) {
handleDrop(box, dropZone);
}
}
}
note

The hide-check-show pattern with elementFromPoint() is the standard technique for detecting drop targets with custom drag and drop. The display toggle happens within a single synchronous block, so the browser never renders the hidden state.

Detecting Drop Targets During Drag (Live Feedback)

For a better user experience, you often want to highlight the drop zone while the user is dragging over it, not just on drop. To do this, run the elementFromPoint check inside mousemove:

<style>
.drop-zone {
width: 200px;
height: 200px;
border: 3px dashed #ccc;
border-radius: 10px;
position: absolute;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 18px;
transition: border-color 0.2s, background 0.2s;
}
.drop-zone.highlight {
border-color: #2ecc71;
background: rgba(46, 204, 113, 0.1);
}
#zone1 { left: 400px; top: 50px; }
#zone2 { left: 400px; top: 300px; }

.drag-item {
width: 80px;
height: 80px;
background: #e67e22;
position: absolute;
left: 50px;
top: 150px;
border-radius: 8px;
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
user-select: none;
}
</style>

<div id="zone1" class="drop-zone">Zone 1</div>
<div id="zone2" class="drop-zone">Zone 2</div>
<div id="item" class="drag-item">Drag</div>

<script>
const item = document.getElementById('item');
let currentDropZone = null;

item.addEventListener('mousedown', (event) => {
event.preventDefault();

const shiftX = event.clientX - item.getBoundingClientRect().left;
const shiftY = event.clientY - item.getBoundingClientRect().top;

item.style.zIndex = 1000;

function onMouseMove(event) {
item.style.left = event.pageX - shiftX + 'px';
item.style.top = event.pageY - shiftY + 'px';

// Detect element below
item.style.display = 'none';
const elemBelow = document.elementFromPoint(event.clientX, event.clientY);
item.style.display = '';

const dropZone = elemBelow?.closest('.drop-zone');

// If we left the previous drop zone, remove highlight
if (currentDropZone && currentDropZone !== dropZone) {
currentDropZone.classList.remove('highlight');
}

// If we entered a new drop zone, add highlight
if (dropZone && dropZone !== currentDropZone) {
dropZone.classList.add('highlight');
}

currentDropZone = dropZone;
}

function onMouseUp(event) {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
item.style.zIndex = '';

if (currentDropZone) {
currentDropZone.classList.remove('highlight');
// Handle the drop
console.log(`Dropped on: ${currentDropZone.id}`);

// Optionally snap the item into the drop zone
const rect = currentDropZone.getBoundingClientRect();
item.style.left = rect.left + rect.width / 2 - item.offsetWidth / 2 + window.scrollX + 'px';
item.style.top = rect.top + rect.height / 2 - item.offsetHeight / 2 + window.scrollY + 'px';
}

currentDropZone = null;
}

document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
</script>

This gives the user visual feedback as they drag over potential drop targets, and snaps the item to the center of the drop zone when released.

A Reusable Drag and Drop System

For real projects, you should encapsulate the drag and drop logic into a reusable function:

function makeDraggable(element, options = {}) {
const {
onDragStart = () => {},
onDragMove = () => {},
onDragEnd = () => {},
dropZoneSelector = null
} = options;

element.addEventListener('mousedown', (event) => {
event.preventDefault();

const shiftX = event.clientX - element.getBoundingClientRect().left;
const shiftY = event.clientY - element.getBoundingClientRect().top;

element.style.zIndex = 1000;
onDragStart(element);

let currentDropZone = null;

function onMouseMove(event) {
const x = event.pageX - shiftX;
const y = event.pageY - shiftY;

element.style.left = x + 'px';
element.style.top = y + 'px';

// Drop zone detection
let dropZone = null;
if (dropZoneSelector) {
element.style.display = 'none';
const below = document.elementFromPoint(event.clientX, event.clientY);
element.style.display = '';
dropZone = below?.closest(dropZoneSelector) || null;
}

// Notify about entering/leaving drop zones
if (currentDropZone !== dropZone) {
if (currentDropZone) currentDropZone.classList.remove('drag-over');
if (dropZone) dropZone.classList.add('drag-over');
currentDropZone = dropZone;
}

onDragMove(element, { x, y, dropZone: currentDropZone });
}

function onMouseUp(event) {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
element.style.zIndex = '';

if (currentDropZone) {
currentDropZone.classList.remove('drag-over');
}

onDragEnd(element, { dropZone: currentDropZone });
currentDropZone = null;
}

document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
}

// Usage
makeDraggable(document.getElementById('item'), {
dropZoneSelector: '.drop-zone',
onDragStart(el) {
el.classList.add('dragging');
},
onDragEnd(el, { dropZone }) {
el.classList.remove('dragging');
if (dropZone) {
console.log(`Dropped on ${dropZone.id}`);
}
}
});

Constraining the Drag Area

Often you want to prevent the user from dragging an element outside a container or off the screen. Add boundary checks inside onMouseMove:

function onMouseMove(event) {
let newX = event.pageX - shiftX;
let newY = event.pageY - shiftY;

// Constrain to the viewport
const maxX = document.documentElement.clientWidth - element.offsetWidth;
const maxY = document.documentElement.clientHeight - element.offsetHeight;

newX = Math.max(0, Math.min(newX, maxX));
newY = Math.max(0, Math.min(newY, maxY));

element.style.left = newX + 'px';
element.style.top = newY + 'px';
}

To constrain within a specific container:

function onMouseMove(event) {
const containerRect = container.getBoundingClientRect();

let newX = event.clientX - shiftX - containerRect.left;
let newY = event.clientY - shiftY - containerRect.top;

// Clamp within container boundaries
newX = Math.max(0, Math.min(newX, containerRect.width - element.offsetWidth));
newY = Math.max(0, Math.min(newY, containerRect.height - element.offsetHeight));

element.style.left = newX + 'px';
element.style.top = newY + 'px';
}

Common Mistakes and Pitfalls

Attaching mousemove to the Element Instead of document

// ❌ Breaks when the mouse moves too fast
box.addEventListener('mousemove', onMouseMove);

// ✅ Always works, even with fast movement
document.addEventListener('mousemove', onMouseMove);

When the mouse moves quickly, it can leave the element between frames. If mousemove is on the element, the listener stops firing and the drag breaks.

Forgetting to Remove Listeners on mouseup

// ❌ Memory leak and ghost dragging
function onMouseDown(event) {
document.addEventListener('mousemove', onMouseMove);
// Forgot to add mouseup listener to clean up!
}

// ✅ Always clean up
function onMouseDown(event) {
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
});
}

Not Preventing Default Drag Behavior

// ❌ Browser's native drag interferes (especially on images)
box.addEventListener('mousedown', (event) => {
// start dragging...
});

// ✅ Prevent native drag
box.addEventListener('mousedown', (event) => {
event.preventDefault();
// start dragging...
});

Also add ondragstart="return false" as an attribute on draggable images to fully suppress the browser's image drag:

<img id="drag-image" src="photo.jpg" ondragstart="return false" />
caution

On touch devices, mouse events behave differently. For cross-device support, use Pointer Events (pointerdown, pointermove, pointerup) instead of mouse events. The algorithm is identical, but Pointer Events work with mouse, touch, and stylus input.

The HTML Drag and Drop API (Overview)

The browser provides a native Drag and Drop API that handles some of the complexity for you. It uses special events like dragstart, drag, dragenter, dragleave, dragover, drop, and dragend.

Basic Usage

<div id="drag-source" draggable="true" style="width: 100px; height: 100px; background: coral;">
Drag me
</div>

<div id="drop-target" style="width: 200px; height: 200px; border: 2px dashed #999;">
Drop here
</div>

<script>
const source = document.getElementById('drag-source');
const target = document.getElementById('drop-target');

// Make the element draggable
source.addEventListener('dragstart', (event) => {
event.dataTransfer.setData('text/plain', source.id);
event.dataTransfer.effectAllowed = 'move';
});

// Allow dropping (must prevent default on dragover)
target.addEventListener('dragover', (event) => {
event.preventDefault(); // Required! Without this, drop won't fire
event.dataTransfer.dropEffect = 'move';
});

// Highlight on drag enter
target.addEventListener('dragenter', (event) => {
target.style.background = '#e8f5e9';
});

// Remove highlight on drag leave
target.addEventListener('dragleave', (event) => {
target.style.background = '';
});

// Handle the drop
target.addEventListener('drop', (event) => {
event.preventDefault();
target.style.background = '';

const id = event.dataTransfer.getData('text/plain');
const draggedElement = document.getElementById(id);
target.appendChild(draggedElement);
});
</script>

Key Features of the HTML Drag and Drop API

The dataTransfer object is central to the native API. It lets you pass data between the drag source and the drop target:

// On dragstart, set data
event.dataTransfer.setData('text/plain', 'some data');
event.dataTransfer.setData('application/json', JSON.stringify({ id: 1 }));

// On drop, read data
const text = event.dataTransfer.getData('text/plain');
const json = JSON.parse(event.dataTransfer.getData('application/json'));

You can also customize the drag image:

source.addEventListener('dragstart', (event) => {
const ghostImage = document.createElement('div');
ghostImage.textContent = 'Moving...';
ghostImage.style.cssText = 'background: coral; padding: 10px; position: absolute; top: -1000px;';
document.body.appendChild(ghostImage);
event.dataTransfer.setDragImage(ghostImage, 0, 0);
});

When to Use the Native API vs. Custom Mouse Events

AspectNative HTML DnD APICustom Mouse Events
Setup complexityLower (browser handles movement)Higher (you handle everything)
Visual controlLimited (ghost image only)Full control over appearance
Drop zone detectionBuilt-in eventsManual with elementFromPoint
Cross-browser consistencySome inconsistenciesYou control the behavior
File drag from desktopSupported nativelyNot possible
Touch supportPoorGood (with Pointer Events)
Custom animation during dragVery limitedFull freedom
Sortable listsPossible but awkwardNatural fit
tip

Use the native HTML Drag and Drop API when you need to drag files from the desktop, transfer data between browser windows, or build simple drag-and-drop without custom visuals.

Use custom mouse/pointer events when you need precise visual control, smooth animations, touch support, or complex interactions like sortable lists.

Limitations of the Native API

The HTML Drag and Drop API has well-known limitations:

  • No drag event coordinates for the element: The drag event fires on the source, but moving the element visually requires workarounds.
  • Touch devices: The native API does not work with touch events on most mobile browsers.
  • Limited visual feedback: You get a semi-transparent ghost image that you cannot style beyond setting a custom drag image.
  • Event quirks: You must call preventDefault() on dragover for drop to fire, which is unintuitive.
  • Cross-browser differences: The behavior of dataTransfer, drag images, and event sequences varies between browsers.

For these reasons, many popular drag-and-drop libraries (SortableJS, dnd-kit, react-beautiful-dnd) implement their own drag system using mouse or pointer events rather than the native API.

Summary

Building drag and drop from mouse events follows a clear pattern:

  1. On mousedown, record the cursor offset within the element (shiftX, shiftY) and attach mousemove and mouseup listeners to document.
  2. On mousemove, update the element's position using pageX - shiftX and pageY - shiftY.
  3. To detect drop targets, temporarily hide the dragged element and use document.elementFromPoint().
  4. On mouseup, remove the listeners and handle the drop.
  5. Always call event.preventDefault() in mousedown to suppress native browser drag behavior.

The native HTML Drag and Drop API is useful for file drops and simple inter-window transfers, but custom mouse event implementations give you far more control over the visual experience and work better across devices.

For production applications, consider using Pointer Events instead of mouse events for unified mouse, touch, and pen support with the same algorithm.