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.
✓ cross-window drags
✗ hard to constrain
✗ weak on touch
✓ direction/area limits
✓ works with touch shims
✗ can’t read OS files
The core algorithm
Every mouse-driven drag’n’drop is the same three-beat rhythm:
mousedownon the element — get ready to move. Maybe switch toposition:absolute, raise itsz-index, add a “being dragged” class, or clone it.mousemoveon the document — reposition the element by writing newleft/topvalues.mouseup— finish up: run the drop logic and detach the handlers you added.
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:
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:
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.
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.
The refined algorithm has two steps:
-
On
mousedown, measure how far the pointer sits from the avatar’s top-left corner and stash it inshiftX/shiftY. Subtract the element’s viewport position (fromgetBoundingClientRect) from the pointer’s viewport coordinates (clientX/clientY):// inside onmousedown let shiftX = event.clientX - avatar.getBoundingClientRect().left; let shiftY = event.clientY - avatar.getBoundingClientRect().top; -
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>):
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>
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:
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.
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:
| previous | now | what runs |
|---|---|---|
| null | seat | enter(seat) — highlight on |
| seat | null | leave(seat) — highlight off |
| seatA | seatB | leave(seatA), then enter(seatB) |
| seat | seat | nothing (no change) |
Drag the avatar over the seat below and watch it light 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:
- Event flow:
avatar.mousedown→document.mousemove→avatar.mouseup. Cancel the browser’s native drag withondragstartreturningfalse, and listen formousemoveondocumentso a fast flick doesn’t lose the pointer. - Grab offset: at
mousedown, recordshiftX/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. - 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.
- Use event delegation: one
mousedown/mouseuphandler on a container that readsevent.targetcan 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.