Your Browser Is Rejecting Every Drop On Purpose
A drop zone with a correctly wired drop listener that never fires — no error, no typo, nothing in the console. The browser is silently refusing the drop by design, and only one specific line stops it.

A teammate pings you: the drag-and-drop on the Kanban board doesn't work. You can pick a card up — it goes translucent, follows the cursor, everything looks right — but dropping it on a column does nothing. No error. No red text in the console. The card just snaps back like the drop never happened.
You open the code. The listener is right there:
Right element. Right event name, spelled correctly. dataTransfer.setData was called in dragstart with the same key. By every reasonable check, this should work. It doesn't — and it isn't going to, no matter how long you stare at this exact function, because the bug isn't in it.
Guess before you scroll: the fix isn't in the drop handler at all. It's a handler that isn't there yet.
The instinct is to suspect draggable. Divs aren't draggable by default the way images and links are, so you add draggable="true" to the card. Now dragstart fires, the ghost image appears, everything about picking up the card works exactly as it should.
Dropping it still does nothing.
Next instinct: maybe dataTransfer needs a different MIME key, or setData/getData need to match more exactly. You double, triple check the strings. They already matched. That was never the problem either.
Here's the part that's easy to miss: the browser doesn't consider your dropzone a valid place to drop anything, and it never told you. Its default behavior for any element receiving a drag is to refuse the drop — the same way it refuses to run a submit button's default action once you call preventDefault() on the form's submit event, except inverted: here, doing nothing is the default, and you have to actively cancel it to unlock the alternative.
The event that carries this default is dragover — the one that fires repeatedly while the dragged item hovers over your element, several times a second, for as long as the drag continues. It sounds like a bookkeeping event, the kind you'd only need for a hover highlight. It is actually the gate:
Add that one handler — nothing fancier, no new logic in drop — and the exact same drop listener from before starts firing. The browser was checking, on every single dragover tick, whether something had canceled its "refuse this drop" default. Nothing had. So by the time the cursor was released, there was nothing to drop onto, and the drop event was simply never dispatched. Not swallowed, not caught somewhere — never sent at all.
Most tutorials also cancel dragenter's default alongside dragover's, and it's worth doing — it lets you flip a highlight class the instant the drag enters instead of waiting for the first dragover tick, and it papers over a few older-Safari quirks. But dragover is the one that's load-bearing. Cancel only dragenter and skip dragover, and you're back to the same silent nothing you started with.
Fix that, and a Kanban board's columns usually need a hover highlight — "you're currently over a valid drop zone" — added and removed as the drag moves. The naive version:
This flickers if the drop zone has any child elements — a card, a label, an icon — because dragenter/dragleave fire per element, and they bubble. Move the cursor from the column onto a card sitting inside it, and the browser fires dragleave on the column (you left it for the card) immediately followed by dragenter on the card. Your highlight class turns off and back on, several times a second, every time the cursor crosses a child boundary. It isn't a bug in your logic — it's the DOM correctly reporting boundary crossings you didn't think to account for.
The fix is to stop trusting "did I leave" and check "did I leave into something outside this element," using the leave event's relatedTarget:
Runs right in your browser — poke at it and watch the concept react live.
One more surprise waits for anyone who tries to log the dragged payload early, to debug the two problems above: call e.dataTransfer.getData("text/plain") inside dragover or dragenter, and you get back an empty string — even though dataTransfer.types correctly lists "text/plain" as available, and even though the exact same call works fine one line later, inside drop.
That's not a race condition and it's not something you configured. The drag data store runs in what the spec calls "protected mode" for the whole drag except the dragstart moment (when it's writable) and the drop moment (when it's finally readable) — a deliberate guard so that a page you're merely dragging over, which never receives the drop, can't read what you're carrying. If you need to know what's being dragged before the drop — to decide, say, whether this drop zone should even light up for this drag — read dataTransfer.types (always available) instead of the data itself.
All of this — dragstart, dragover, drop, the whole dataTransfer dance — is a mouse-and-desktop story. No major mobile browser fires these events for a finger drag; the touch input model and the HTML Drag and Drop spec were never wired together. If your Kanban board needs to be reorderable on a phone, this API isn't the one that gets you there — you're reaching for Pointer Events with your own hit-testing, or a library like SortableJS that's already done that work. Ship native drag-and-drop as a desktop enhancement, not the only way in.
The teammate's bug report wasn't wrong, and neither was their code. It was missing one handler whose entire job is invisible until you know to look for it — the browser was waiting, tick after tick, for a preventDefault() call that never came, and nothing about a missing drop event tells you that's what's missing.
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
Go check your own drop zones. If you've got a drop listener with no dragover listener next to it, it's not broken by accident — it's working exactly as specified, refusing every drop on purpose. What's the last time a "the listener's just not firing" bug turned out to be a default you needed to cancel somewhere else entirely?
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.