Skip to content

How to Implement Drag and Drop with Mouse Events in JavaScript

How to build custom drag and drop interactions from scratch using JavaScript mouse events (mousedown, mousemove, mouseup) for full control over positioning, visual feedback, and drop detection.

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>
    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.

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.

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);
  }
</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);
    }
  }
}

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);
  }
  
  .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" />

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

Aspect Native HTML DnD API Custom Mouse Events
Setup complexity Lower (browser handles movement) Higher (you handle everything)
Visual control Limited (ghost image only) Full control over appearance
Drop zone detection Built-in events Manual with elementFromPoint
Cross-browser consistency Some inconsistencies You control the behavior
File drag from desktop Supported natively Not possible
Touch support Poor Good (with Pointer Events)
Custom animation during drag Very limited Full freedom
Sortable lists Possible but awkward Natural fit

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.

Navigation