Drag'n'Drop with mouse events

Drag’n’drop is one of those interactions that feels obvious the moment you use it. You grab a thing, move it, let go. That single gesture covers a huge range of tasks: moving files between folders, reordering a to-do list, dropping a product into a cart, rearranging cards on a board. When it works, nobody thinks about it. Your job is to make it work.

There are two ways to build it in the browser, and it helps to know why we pick one over the other.

Two approaches: native DnD vs. mouse events

The HTML standard ships a dedicated Drag and Drop API with its own events: dragstart, dragover, drop, dragend, and friends. It exists, and it has one genuine superpower.

For everything happening inside the page, though, the native API fights you:

  • You can’t easily stop a drag from starting in a particular region.
  • You can’t constrain movement to horizontal-only or vertical-only.
  • Lots of custom behaviors (live constraints, snapping, custom drag images that actually cooperate) are awkward or impossible.
  • Support on touch devices is weak.

So for in-page dragging — the common case — we build it ourselves from plain mouse events. You get full control, and the logic is short.

Native DnD events
✓ files dropped from the OS
✓ cross-window drags
✗ hard to constrain
✗ weak on touch
Mouse events (this lesson)
✓ full control over motion
✓ direction/area limits
✓ works with touch shims
✗ can’t read OS files
Choosing an approach: file-from-OS drops need native events; in-page control belongs to mouse events.

The core algorithm

Every mouse-driven drag’n’drop is the same three-beat rhythm:

  1. mousedown on the element — get ready to move. Maybe switch to position:absolute, raise its z-index, add a “being dragged” class, or clone it.
  2. mousemove on the document — reposition the element by writing new left/top values.
  3. mouseup — finish up: run the drop logic and detach the handlers you added.
1 · mousedown
on the avatar
prepare & grab
2 · mousemove
on document
update left/top
3 · mouseup
on the avatar
drop & clean up
The event flow. Notice the middle step listens on document, not on the element.

That’s the whole skeleton. Highlighting drop zones and constraining movement are extras you bolt on later. Here’s a first pass at dragging an avatar chip:

avatar.onmousedown = function(event) {
  // (1) prepare to move: make it absolute and raise it above everything
  avatar.style.position = 'absolute';
  avatar.style.zIndex = 1000;

  // pull it out of its current parent and drop it straight into <body>,
  // so its left/top are measured against the page, not some inner box
  document.body.append(avatar);

  // put the avatar's center at (pageX, pageY)
  function moveAt(pageX, pageY) {
    avatar.style.left = pageX - avatar.offsetWidth / 2 + 'px';
    avatar.style.top = pageY - avatar.offsetHeight / 2 + 'px';
  }

  // snap it under the pointer right away
  moveAt(event.pageX, event.pageY);

  function onMouseMove(event) {
    moveAt(event.pageX, event.pageY);
  }

  // (2) follow the pointer while it moves
  document.addEventListener('mousemove', onMouseMove);

  // (3) on release: drop it and remove the move handler
  avatar.onmouseup = function() {
    document.removeEventListener('mousemove', onMouseMove);
    avatar.onmouseup = null;
  };

};

Run that and something odd happens. The instant you start dragging, the avatar seems to fork — you end up dragging a ghostly copy of it:

interactiveThe naive first pass — watch the avatar fork

Give it a try and you’ll see the split.

Why the avatar forks: the browser’s own drag

The culprit is a feature you didn’t ask for. Browsers have built-in drag’n’drop for images and links — it’s what lets you drag a photo from a page onto your desktop. That native behavior kicks in automatically and collides with our mouse-event logic, producing the phantom clone.

The fix is one handler. dragstart fires when the native drag is about to begin; returning false cancels it:

avatar.ondragstart = function() {
  return false;
};

With native dragging suppressed, only our code moves the avatar, and the fork disappears:

interactiveOne line of defense: ondragstart returns false

Why mousemove lives on document, not the avatar

Look again at step 2. We attach mousemove to document, even though it feels like the pointer is always sitting on the avatar. Why not just listen on the avatar itself?

Because mousemove doesn’t fire for every pixel the cursor crosses. It fires periodically, and between two firings the pointer can travel a long way. Move fast and the cursor can leap off the avatar entirely — landing in the middle of the page, or even outside the window — before the next event. If your handler were bound to the avatar, no more events would arrive and the avatar would freeze mid-drag.

avatareventeventevent (off the avatar!)
A fast flick: mousemove samples the path, so consecutive events can land far apart — sometimes off the element.

Listening on document guarantees you catch every mousemove no matter where the pointer wanders, so the avatar keeps following it.

Keeping the grab point: correct positioning

The first version always centers the avatar on the pointer:

avatar.style.left = pageX - avatar.offsetWidth / 2 + 'px';
avatar.style.top = pageY - avatar.offsetHeight / 2 + 'px';

Works, but it feels wrong. You can start a drag by pressing down anywhere on the avatar — yet the moment you do, the avatar jumps so its center snaps under the cursor. Grab it by the edge and it lurches sideways. Real drag’n’drop should keep whatever point you grabbed exactly under the pointer for the whole drag.

top-leftpointershiftXshiftY
shiftX / shiftY: the distance from the pointer to the avatar's top-left corner, captured at mousedown.

The refined algorithm has two steps:

  1. On mousedown, measure how far the pointer sits from the avatar’s top-left corner and stash it in shiftX/shiftY. Subtract the element’s viewport position (from getBoundingClientRect) from the pointer’s viewport coordinates (clientX/clientY):

    // inside onmousedown
    let shiftX = event.clientX - avatar.getBoundingClientRect().left;
    let shiftY = event.clientY - avatar.getBoundingClientRect().top;
  2. While dragging, place the avatar so that same offset is preserved — subtract the shift from the pointer’s page coordinates:

    // inside onmousemove; avatar has position:absolute
    avatar.style.left = event.pageX - shiftX + 'px';
    avatar.style.top  = event.pageY - shiftY + 'px';

Putting it together, with the grab-offset lines and the disabled native drag highlighted:

avatar.onmousedown = function(event) {

  let shiftX = event.clientX - avatar.getBoundingClientRect().left;
  let shiftY = event.clientY - avatar.getBoundingClientRect().top;

  avatar.style.position = 'absolute';
  avatar.style.zIndex = 1000;
  document.body.append(avatar);

  moveAt(event.pageX, event.pageY);

  // move the avatar to (pageX, pageY), preserving the initial grab offset
  function moveAt(pageX, pageY) {
    avatar.style.left = pageX - shiftX + 'px';
    avatar.style.top = pageY - shiftY + 'px';
  }

  function onMouseMove(event) {
    moveAt(event.pageX, event.pageY);
  }

  // follow the pointer
  document.addEventListener('mousemove', onMouseMove);

  // drop the avatar and detach the move handler
  avatar.onmouseup = function() {
    document.removeEventListener('mousemove', onMouseMove);
    avatar.onmouseup = null;
  };

};

avatar.ondragstart = function() {
  return false;
};

In action (inside an <iframe>):

interactiveGrab offset — the avatar holds whatever point you pressed

The improvement is easiest to feel if you grab the avatar by its bottom-right corner. Before, it snapped its center under the pointer. Now it glides along, holding whatever spot you grabbed.

Drop targets (droppables)

So far the avatar just stays wherever you release it. Real interfaces care about what you dropped it on — a file onto a folder, a card onto a column, a photo into an album. In the general shape of the problem, you drag a draggable and release it over a droppable.

You want two things:

  • The droppable under the pointer at the moment of release, so you can act on the drop.
  • Ideally, the droppable you’re currently hovering during the drag, so you can highlight it.

The obvious plan is to put mouseover/mouseup handlers on each potential droppable and wait for them to fire. It doesn’t work, and the reason is worth understanding.

Why handlers on droppables never fire

While a drag is in progress, the avatar sits on top of everything (that’s the point of the high z-index). Mouse events only reach the topmost element at a given point — never the elements hidden beneath it. So the avatar intercepts every event, and the droppable underneath it stays silent.

You can see the effect with two overlapping boxes. The coral one fully covers the teal one, so the teal one’s handler is unreachable:

<style>
  div {
    width: 50px;
    height: 50px;
    position: absolute;
    top: 0;
  }
</style>
<div style="background:teal" onmouseover="alert('never fires')"></div>
<div style="background:coral" onmouseover="alert('on top!')"></div>
teal (below)coral (top)event → coral only
Only the top element receives the event. Anything beneath it is invisible to the pointer.

The dragged avatar behaves exactly like the coral box. Whatever handlers you register on the elements below, they won’t run.

Hover across the two boxes below. Both have a mouseover handler, but where they overlap only the top (coral) one ever reports — the teal box beneath it is unreachable there:

interactiveOnly the topmost element receives the event

document.elementFromPoint to the rescue

Instead of waiting for events to bubble up from below, ask the browser directly: what element is at these coordinates? That’s document.elementFromPoint(clientX, clientY). It returns the topmost, most deeply nested element at those viewport-relative coordinates, or null if the point lies outside the visible window.

There’s one catch. Call it during a drag and the answer is almost always the avatar itself, since the avatar is on top. The trick is to hide the avatar for the split second it takes to look:

// inside a mouse event handler
avatar.hidden = true; // (*) hide the avatar so it isn't the topmost element

let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
// elemBelow is whatever is under the pointer now — possibly a droppable

avatar.hidden = false; // show it again immediately

The hide/show happens synchronously within the handler, so the avatar never visibly flickers. Between those two lines, elementFromPoint sees straight through to the element underneath.

avatar.hidden = true
elementFromPoint(x, y)
avatar.hidden = false
Hide the avatar → ask elementFromPoint → show the avatar. All in one synchronous handler, so no flicker.

Tracking enter and leave

Now wire that lookup into onMouseMove. Every move, find the element below, climb to the nearest .droppable ancestor with closest, and compare it to the droppable you were over last time. A change means you either flew into a new droppable or out of the old one:

// the droppable we're currently hovering, null if none
let currentDroppable = null;

function onMouseMove(event) {
  moveAt(event.pageX, event.pageY);

  avatar.hidden = true;
  let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
  avatar.hidden = false;

  // mousemove can fire when the avatar is dragged past the window edge;
  // then clientX/clientY are outside the viewport and elementFromPoint returns null
  if (!elemBelow) return;

  // droppables are marked with class "droppable" (your logic may differ)
  let droppableBelow = elemBelow.closest('.droppable');

  if (currentDroppable != droppableBelow) {
    // the droppable under the pointer changed — either value may be null:
    //   currentDroppable = null  → we weren't over a droppable before (empty space)
    //   droppableBelow    = null → we aren't over a droppable now
    if (currentDroppable) {
      // "flying out" — remove the highlight
      leaveDroppable(currentDroppable);
    }
    currentDroppable = droppableBelow;
    if (currentDroppable) {
      // "flying in" — add the highlight
      enterDroppable(currentDroppable);
    }
  }
}

The state machine is small but easy to get wrong. Four transitions come out of comparing the previous droppable to the current one:

previousnowwhat runs
nullseatenter(seat) — highlight on
seatnullleave(seat) — highlight off
seatAseatBleave(seatA), then enter(seatB)
seatseatnothing (no change)
Only when the two differ do we fire leave (old) then enter (new). Either side can be null.

Drag the avatar over the seat below and watch it light up as you enter:

interactiveDrop targets — the seat lights up as you enter

After this, currentDroppable always holds the drop target under the pointer throughout the drag. Highlight it, validate it, snap to it — whatever the interface needs.

Summary

You now have a full drag’n’drop foundation built from mouse events.

The load-bearing pieces:

  1. Event flow: avatar.mousedowndocument.mousemoveavatar.mouseup. Cancel the browser’s native drag with ondragstart returning false, and listen for mousemove on document so a fast flick doesn’t lose the pointer.
  2. Grab offset: at mousedown, record shiftX/shiftY — how far the pointer is from the element’s top-left corner — and subtract it while positioning, so the element tracks the pointer smoothly instead of jumping.
  3. Drop targets: use document.elementFromPoint(clientX, clientY) (hiding the dragged element around the call) to find what’s underneath, then track enter/leave transitions.

From here you can go a long way:

  • On mouseup, finalize the drop: move data, rearrange elements, persist the new order.
  • Highlight the droppable you’re hovering.
  • Constrain dragging to an area or a single axis. The handle below is locked to its track: only the horizontal coordinate changes, and it’s clamped to the track’s width.
interactiveAxis-constrained drag: a slider built from mouse events
  • Use event delegation: one mousedown/mouseup handler on a container that reads event.target can manage drag’n’drop for hundreds of elements at once.

Libraries wrap this in classes like Draggable, Droppable, and DragZone. Under the hood most of them do roughly what you just built — so you can read their source with confidence, or write your own when that’s simpler than bending a third-party tool to your needs.