I recently built a drag-and-drop component for React. To demonstrate how to use it, I went pretty deep and created a Trello-like drag-and-drop board with it.
And like with any new code, I found a few bugs to fix along the way.
Some of them were pretty odd!
I'm writing this post as a reference for me (and you) to remember how to fix these bugs in our future drag-and-drop creations.
I'm using Tailwind CSS classes in this blog post, if you don't use Tailwind, you can grab the native styles from the Tailwind docs.
1. Draggable children inside draggable parents
Sometimes, you need to create drag-and-drop items inside other drag-and-drop items - for example, in Trello, you can drag-and-drop cards inside drag-and-drop lists.
The problem you might come up against is that when you drag and drop a card, it can also trigger the parent drag-and-drop event.
Here's an example:
<script> function dragStart(event, type) { event.dataTransfer.dropEffect = "move"; console.log(type); }; </script> <div class="p-10"> <ul class="rounded bg-gray-100 p-10 w-80" draggable="true" ondragstart="dragStart(event, 'list')"> <li class="rounded bg-white border p-5 my-1" draggable="true" ondragstart="dragStart(event, 'item')">Item 1</li> <li class="rounded bg-white border p-5 my-1" draggable="true" ondragstart="dragStart(event, 'item')">Item 2</li> </ul> </div>
Check the console. When you drag a list, you get "list" in the console.
But when you drag an item, you get "item" quickly followed by "list".
That's because the event is bubbling up from the item and triggering the "dragstart" event for the list.
🔧 The fix
The fix for this problem is to add event.stopPropagation();
to your "dragstart" event handler. If you have an event handler for the "drag" event, add it there too!
function dragStart(event, type) {
event.stopPropagation(); // 🆕
event.dataTransfer.dropEffect = "move";
console.log(type);
};
This code will stop your drag-and-drop events from bubbling up to the parent.
<script> function dragStart(event, type) { event.stopPropagation(); event.dataTransfer.dropEffect = "move"; console.log(type); }; </script> <div class="p-10"> <ul class="rounded bg-gray-100 p-10 w-80" draggable="true" ondragstart="dragStart(event, 'list')"> <li class="rounded bg-white border p-5 my-1" draggable="true" ondragstart="dragStart(event, 'item')">Item 1</li> <li class="rounded bg-white border p-5 my-1" draggable="true" ondragstart="dragStart(event, 'item')">Item 2</li> </ul> </div>
2. Hiding the original element
To fade out a dragged element, reducing the opacity on "dragstart" will get the job done.
<script> function dragStart(event, type) { event.stopPropagation(); event.dataTransfer.dropEffect = "move"; event.target.classList.add("opacity-50"); }; function dragEnd(event) { event.target.classList.remove("opacity-50"); }; </script> <div class="p-10"> <ul class="rounded bg-gray-100 p-10 w-80" draggable="true" ondragstart="dragStart(event, 'list')" ondragend="dragEnd(event)"> <li class="rounded bg-white border p-5 my-1" draggable="true" ondragstart="dragStart(event, 'item')" ondragend="dragEnd(event)">Item 1</li> <li class="rounded bg-white border p-5 my-1" draggable="true" ondragstart="dragStart(event, 'item')" ondragend="dragEnd(event)">Item 2</li> </ul> </div>
No problem!
But here's the bug. If you want to hide the original element to remove it while it gets dragged, adding display: none;
on the "dragstart" event won't work.
<script> function dragStart(event, type) { event.stopPropagation(); event.dataTransfer.dropEffect = "move"; event.target.classList.add("hidden"); }; function dragEnd(event) { event.target.classList.remove("hidden"); }; </script> <div class="p-10"> <ul class="rounded bg-gray-100 p-10 w-80" draggable="true" ondragstart="dragStart(event, 'list')" ondragend="dragEnd(event)"> <li class="rounded bg-white border p-5 my-1" draggable="true" ondragstart="dragStart(event, 'item')" ondragend="dragEnd(event)">Item 1</li> <li class="rounded bg-white border p-5 my-1" draggable="true" ondragstart="dragStart(event, 'item')" ondragend="dragEnd(event)">Item 2</li> </ul> </div>
It breaks the drag-and-drop!
Why? Because if you hide the element on the "dragstart" event, no drag image gets created. And because the dragged item immediately disappears, and there's no drag image, the drag event ends.
I found this issue when I built my Trello-like drag and drop board - and it drove me crazy!
🔧 The fix
To fix this bug, we'll need to hide the original element after the "dragstart" event to let the drag image get created first.
A few solutions floating around the internet involved adding a setTimeout()
to delay adding the style. Like this:
function dragStart(event, type) {
event.stopPropagation();
event.dataTransfer.dropEffect = "move";
setTimeout(() => {
event.target.classList.add("hidden");
}, "200"); // delay adding the style by 200ms
};
And that does work. But another fix I prefer is to add the style after the "dragstart" event by adding it in the "drag" event.
function dragStart(event, type) {
event.stopPropagation();
event.dataTransfer.dropEffect = "move";
};
function drag(event) {
event.stopPropagation();
event.target.classList.add("hidden"); // add the style here instead!
};
Either of these solutions will fix the problem and let us hide the original drag element during a drag.
<script> function dragStart(event, type) { event.stopPropagation(); event.dataTransfer.dropEffect = "move"; }; function drag(event) { event.stopPropagation(); event.target.classList.add("hidden"); }; function dragEnd(event) { event.target.classList.remove("hidden"); }; </script> <div class="p-10"> <ul class="rounded bg-gray-100 p-10 w-80" draggable="true" ondragstart="dragStart(event, 'list')" ondrag="drag(event)" ondragend="dragEnd(event)"> <li class="rounded bg-white border p-5 my-1" draggable="true" ondragstart="dragStart(event, 'item')" ondrag="drag(event)" ondragend="dragEnd(event)">Item 1</li> <li class="rounded bg-white border p-5 my-1" draggable="true" ondragstart="dragStart(event, 'item')" ondrag="drag(event)" ondragend="dragEnd(event)">Item 2</li> </ul> </div>
3. Rounded corners on the drag image
If you look very closely when you drag an item, you'll see the drag image doesn't have rounded borders. A little bit of the light grey background gets included in the square corners.
I'll admit, it's hard to see with that light grey background.
If I make the background red, the problem becomes much more glaring!
<script> function dragStart(event, type) { event.stopPropagation(); event.dataTransfer.dropEffect = "move"; }; function drag(event) { event.stopPropagation(); event.target.classList.add("hidden"); }; function dragEnd(event) { event.target.classList.remove("hidden"); }; </script> <div class="p-10"> <ul class="rounded bg-red-500 p-10 w-80" draggable="true" ondragstart="dragStart(event, 'list')" ondrag="drag(event)" ondragend="dragEnd(event)"> <li class="rounded bg-white border p-5 my-1" draggable="true" ondragstart="dragStart(event, 'item')" ondrag="drag(event)" ondragend="dragEnd(event)">Item 1</li> <li class="rounded bg-white border p-5 my-1" draggable="true" ondragstart="dragStart(event, 'item')" ondrag="drag(event)" ondragend="dragEnd(event)">Item 2</li> </ul> </div>
Fugly!
🔧 The fix
There's a nice simple fix for this - and I can't take credit for it.
I found the solution in this GitHub issue - use the transform: 'translate(0, 0)'
style.
In Tailwind CSS, that's the "translate-x-0" class.
<script> function dragStart(event, type) { event.stopPropagation(); event.dataTransfer.dropEffect = "move"; }; function drag(event) { event.stopPropagation(); event.target.classList.add("hidden"); }; function dragEnd(event) { event.target.classList.remove("hidden"); }; </script> <div class="p-10"> <ul class="rounded bg-red-500 p-10 w-80 translate-x-0" draggable="true" ondragstart="dragStart(event, 'list')" ondrag="drag(event)" ondragend="dragEnd(event)"> <li class="rounded bg-white border p-5 my-1 translate-x-0" draggable="true" ondragstart="dragStart(event, 'item')" ondrag="drag(event)" ondragend="dragEnd(event)">Item 1</li> <li class="rounded bg-white border p-5 my-1 translate-x-0" draggable="true" ondragstart="dragStart(event, 'item')" ondrag="drag(event)" ondragend="dragEnd(event)">Item 2</li> </ul> </div>
The corners are round in the drag image, and all is well in the drag-and-drop world*!
*I lied - all is not well in the drag-and-drop world - we are not even halfway through the drag-and-drop gotchas yet! But at least our corners are round!
4. Rotating the drag image
In another drag-and-drop build, I needed to rotate the drag image slightly. To give it a quirky "I'm being picked up and dragged" vibe.
You might imagine you can do something like this on the "dragstart" event:
function dragStart(event, type) {
event.stopPropagation();
event.dataTransfer.dropEffect = "move";
event.target.classList.add("rotate-6"); // rotate the drag image?
};
// ...
function dragEnd(event) {
event.target.classList.remove("hidden");
event.target.classList.remove("rotate-6"); // remove rotation when done
};
I certainly thought that would work.
It doesn't.
Well, you might see the original element rotate slightly for all of one millisecond. But that's not what we are after.
🔧 The fix
You can't rotate the drag element. Sorry to break that to you - but it's true.
But you can rotate the thing inside the draggable element, which gives the appearance of rotating the dragged element.
For example, if we wrap our list ul
in a div
and make the div
draggable...
<div class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'list')" ondrag="drag(event)" ondragend="dragEnd(event)">
<ul class="rounded bg-gray-100 p-10 w-80">
<!-- ... -->
</ul>
</div>
We can then rotate the ul
.
function dragStart(event, type) {
event.stopPropagation();
event.dataTransfer.dropEffect = "move";
event.target.firstElementChild.classList.add("rotate-6"); // rotate the child
};
function drag(event) {
event.stopPropagation();
event.target.classList.add("hidden");
};
function dragEnd(event) {
event.target.classList.remove("hidden");
event.target.firstElementChild.classList.remove("rotate-6"); // remove rotation
};
And we can do the same thing for items to get our drags rotating nicely:
<script> function dragStart(event, type) { event.stopPropagation(); event.dataTransfer.dropEffect = "move"; event.target.firstElementChild.classList.add("rotate-6"); }; function drag(event) { event.stopPropagation(); event.target.classList.add("hidden"); }; function dragEnd(event) { event.target.classList.remove("hidden"); event.target.firstElementChild.classList.remove("rotate-6"); }; </script> <div class="p-10"> <div class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'list')" ondrag="drag(event)" ondragend="dragEnd(event)"> <ul class="rounded bg-gray-100 p-10 w-80"> <li class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'item')" ondrag="drag(event)" ondragend="dragEnd(event)"><div class="rounded bg-white border p-5 my-1 ">Item 1</div></li> <li class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'item')" ondrag="drag(event)" ondragend="dragEnd(event)"><div class="rounded bg-white border p-5 my-1 ">Item 2</div></li> </ul> </div> </div>
5. A draggable item with a link inside
Let's change our items from div
elements to a
elements - a.k.a. make them into links.
<script> function dragStart(event, type) { event.stopPropagation(); event.dataTransfer.dropEffect = "move"; event.target.firstElementChild.classList.add("rotate-6"); }; function drag(event) { event.stopPropagation(); event.target.classList.add("hidden"); }; function dragEnd(event) { event.target.classList.remove("hidden"); event.target.firstElementChild.classList.remove("rotate-6"); }; </script> <div class="p-10"> <div class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'list')" ondrag="drag(event)" ondragend="dragEnd(event)"> <ul class="rounded bg-gray-100 p-10 w-80"> <li class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'item')" ondrag="drag(event)" ondragend="dragEnd(event)"><a href="#" class="block rounded bg-white border p-5 my-1">Item 1</a></li> <li class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'item')" ondrag="drag(event)" ondragend="dragEnd(event)"><a href="#" class="block rounded bg-white border p-5 my-1">Item 2</a></li> </ul> </div> </div>
Oops, that broke the drag-and-drop for the items.
What is that weird grey link thingy that replaced our super cool jaunty-angle drag image? We worked hard on that!
🔧 The fix
Links are draggable by default.
If you need a link inside your draggable item, you need to turn off that default behaviour so it doesn't break your drag-and-drop.
And luckily, this one is a simple fix. We can add draggable="false"
to the link element.
<a href="#" draggable="false">
<script> function dragStart(event, type) { event.stopPropagation(); event.dataTransfer.dropEffect = "move"; event.target.firstElementChild.classList.add("rotate-6"); }; function drag(event) { event.stopPropagation(); event.target.classList.add("hidden"); }; function dragEnd(event) { event.target.classList.remove("hidden"); event.target.firstElementChild.classList.remove("rotate-6"); }; </script> <div class="p-10"> <div class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'list')" ondrag="drag(event)" ondragend="dragEnd(event)"> <ul class="rounded bg-gray-100 p-10 w-80"> <li class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'item')" ondrag="drag(event)" ondragend="dragEnd(event)"><a href="#" class="block rounded bg-white border p-5 my-1" draggable="false">Item 1</a></li> <li class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'item')" ondrag="drag(event)" ondragend="dragEnd(event)"><a href="#" class="block rounded bg-white border p-5 my-1" draggable="false">Item 2</a></li> </ul> </div> </div>
Woop, woop! Back up and running! I wonder what other drag-and-drop surprises await us.
6. Drag leave event firing on child elements
To show you this bug, we need to add a drop zone.
<div class="m-1 rounded border border-dashed border-blue-300 p-10 w-80 h-56" ondragenter="dragEnter(event)" ondragover="dragOver(event)" ondragleave="dragLeave(event)" ondrop="drop(event)">
<!-- the drop zone -->
</div>
To designate this div
as a drop zone, we event.preventDefault()
on the "dragover" event and handle the "drop" event.
When the user enters the drop zone, we want to indicate that, so we will add a light blue background to show them that the drop zone is active.
// drop events
function dragEnter(event) {
console.log("I entered");
event.target.classList.add("bg-blue-50");
};
function dragOver(event) {
event.preventDefault();
};
function dragLeave(event) {
console.log("I left... or did I?");
event.target.classList.remove("bg-blue-50");
};
function drop(event) {
console.log("I dropped");
event.target.classList.remove("bg-blue-50");
};
And that all works great!
The problem is if you have some other elements inside your drop zone. I'll add a div
and some text to demonstrate this issue.
<script> // drag events function dragStart(event, type) { event.stopPropagation(); event.dataTransfer.dropEffect = "move"; event.target.firstElementChild.classList.add("rotate-6"); }; function drag(event) { event.stopPropagation(); event.target.classList.add("hidden"); }; function dragEnd(event) { event.target.classList.remove("hidden"); event.target.firstElementChild.classList.remove("rotate-6"); }; // drop events function dragEnter(event) { console.log("I entered"); event.target.classList.add("bg-blue-50"); }; function dragOver(event) { event.preventDefault(); }; function dragLeave(event) { console.log("I left... or did I?"); event.target.classList.remove("bg-blue-50"); }; function drop(event) { console.log("I dropped"); event.target.classList.remove("bg-blue-50"); }; </script> <div class="p-10 flex"> <div class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'list')" ondrag="drag(event)" ondragend="dragEnd(event)"> <ul class="m-1 rounded bg-gray-100 p-10 w-80"> <li class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'item')" ondrag="drag(event)" ondragend="dragEnd(event)"><a href="#" class="block rounded bg-white border p-5 my-1" draggable="false">Item 1</a></li> <li class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'item')" ondrag="drag(event)" ondragend="dragEnd(event)"><a href="#" class="block rounded bg-white border p-5 my-1" draggable="false">Item 2</a></li> </ul> </div> <div class="m-1 rounded border border-dashed border-blue-300 p-10 w-80 h-56" ondragenter="dragEnter(event)" ondragover="dragOver(event)" ondragleave="dragLeave(event)" ondrop="drop(event)"> <div class="text-blue-300 font-bold text-center py-12">The Drop Zone</div> </div> </div>
If we drag an item over the drop zone, the "dragleave" event gets fired when we pass over the text div
element. The "dragenter" event is fired again, but the blue background gets applied to the inner div element (instead of our drop zone).
Because we have a new event.target
.
This behaviour can lead to all kinds of problems. Not just the background problem, but also knowing what drop zone is active when you come to handle your drops!
🔧 The fix
This one is a little bit of a pain to fix (but worth it).
Fixing this problem will make your drop zones much more reliable, whatever goes in them.
To fix it, we need to create an "absolute" positioned div
inside the drop zone to listen for the "dragleave" event. It needs to be the last child of our drop zone so that it will cover any other children to stop them from interfering with our drop.
<div class="relative m-1 rounded border border-dashed border-blue-300 p-10 w-80 h-56" ondragenter="dragEnter(event)" ondragover="dragOver(event)" ondrop="drop(event)">
<div class="text-blue-300 font-bold text-center py-12">The Drop Zone</div>
<div class="absolute inset-0 hidden" ondragleave="dragLeave(event)"></div>
</div>
Now we need to tweak the code slightly, to show to "absolute" div when we are in the drop zone, and make sure the "dragenter" event is on the drop zone and not on this one child element.
// drop events
function dragEnter(event) {
console.log("I entered");
// add the background to the drop zone
event.currentTarget.classList.add("bg-blue-50");
// show the absolute div
// if it's the drop zone
// the absolute element will be the last child element
event.currentTarget.lastElementChild.classList.remove("hidden");
};
// same as before
function dragOver(event) {
event.preventDefault();
};
// now triggered by the absolute element
function dragLeave(event) {
console.log("I left");
// remove the background from the drop zone
event.target.parentElement.classList.remove("bg-blue-50");
// hide the absolute element again
event.target.classList.add("hidden");
};
// now triggered by the absolute element
function drop(event) { // now triggered by the absolute element
console.log("I dropped");
// remove the background from the drop zone
event.currentTarget.classList.remove("bg-blue-50");
// hide the absolute element again
event.currentTarget.lastElementChild.classList.add("hidden");
};
<script> // drag events function dragStart(event, type) { event.stopPropagation(); event.dataTransfer.dropEffect = "move"; }; function drag(event) { event.stopPropagation(); event.target.classList.add("opacity-50"); }; function dragEnd(event) { event.target.classList.remove("opacity-50"); }; // drop events function dragEnter(event) { if (event.target.lastElementChild) { console.log("I entered"); event.target.classList.add("bg-blue-50"); event.target.lastElementChild.classList.remove("hidden"); } }; function dragOver(event) { event.preventDefault(); }; function dragLeave(event) { console.log("I left"); event.target.parentElement.classList.remove("bg-blue-50"); event.target.classList.add("hidden"); }; function drop(event) { console.log("I dropped"); event.currentTarget.classList.remove("bg-blue-50"); event.currentTarget.lastElementChild.classList.add("hidden"); }; </script> <div class="p-10 flex"> <div class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'list')" ondrag="drag(event)" ondragend="dragEnd(event)"> <ul class="m-1 rounded bg-gray-100 p-10 w-80"> <li class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'item')" ondrag="drag(event)" ondragend="dragEnd(event)"><a href="#" class="block rounded bg-white border p-5 my-1" draggable="false">Item 1</a></li> <li class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'item')" ondrag="drag(event)" ondragend="dragEnd(event)"><a href="#" class="block rounded bg-white border p-5 my-1" draggable="false">Item 2</a></li> </ul> </div> <div class="relative m-1 rounded border border-dashed border-blue-300 p-10 w-80 h-56" ondragenter="dragEnter(event)" ondragover="dragOver(event)" ondrop="drop(event)"> <div class="text-blue-300 font-bold text-center py-12">The Drop Zone</div> <div class="absolute inset-0 hidden" ondragleave="dragLeave(event)"></div> </div> </div>
7. Dragging over an iframe breaks drag-and-drop
This drag-and-drop problem came out of nowhere and is a beauty. It happened when I created a file upload drag-and-drop component, and it was in a modal.
Unknown to me, there was a YouTube video underneath the modal (out of view!) interfering with my drag-and-drop.
So it took some bug hunting!
I am not sure if this is just a Chrome issue or a browser-wide issue.
This issue might not even be an issue anymore. It was a while ago that I ran into this problem, and I was not able to recreate it today.
But for my own reference, and in case this issue does pop back up again in the future, this is what I did.
This fix is also good if you need to be able to drag and drop an iframe (like a video).
🔧 The fix
If you want to drag and drop a video or an iframe, you might struggle - you can't just grab it.
<script> // drag events function dragStart(event, type) { event.stopPropagation(); event.dataTransfer.dropEffect = "move"; }; function drag(event) { event.stopPropagation(); event.target.classList.add("opacity-50"); }; function dragEnd(event) { event.target.classList.remove("opacity-50"); }; // drop events function dragEnter(event) { if (event.target.lastElementChild) { console.log("I entered"); event.target.classList.add("bg-blue-50"); event.target.lastElementChild.classList.remove("hidden"); } }; function dragOver(event) { event.preventDefault(); }; function dragLeave(event) { console.log("I left"); event.target.parentElement.classList.remove("bg-blue-50"); event.target.classList.add("hidden"); }; function drop(event) { console.log("I dropped"); event.currentTarget.classList.remove("bg-blue-50"); event.currentTarget.lastElementChild.classList.add("hidden"); }; </script> <div class="p-10 flex"> <div class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'list')" ondrag="drag(event)" ondragend="dragEnd(event)"> <iframe class="m-1 rounded" width="420" height="315" src="https://www.youtube.com/embed/tgbNymZ7vqY?&mute=1"> </iframe> </div> <div class="relative m-1 rounded border border-dashed border-blue-300 p-10 w-80 h-56" ondragenter="dragEnter(event)" ondragover="dragOver(event)" ondrop="drop(event)"> <div class="text-blue-300 font-bold text-center py-12">The Drop Zone</div> <div class="absolute inset-0 hidden" ondragleave="dragLeave(event)"></div> </div> </div>
To fix this issue, I add pointer-events: none;
(or in Tailwind CSS that's the "pointer-events-none" class) to the iframe.
And that stops the iframe from interfering with the drag-and-drop.
<script> // drag events function dragStart(event, type) { event.stopPropagation(); event.dataTransfer.dropEffect = "move"; }; function drag(event) { event.stopPropagation(); event.target.classList.add("opacity-50"); }; function dragEnd(event) { event.target.classList.remove("opacity-50"); }; // drop events function dragEnter(event) { if (event.target.lastElementChild) { console.log("I entered"); event.target.classList.add("bg-blue-50"); event.target.lastElementChild.classList.remove("hidden"); } }; function dragOver(event) { event.preventDefault(); }; function dragLeave(event) { console.log("I left"); event.target.parentElement.classList.remove("bg-blue-50"); event.target.classList.add("hidden"); }; function drop(event) { console.log("I dropped"); event.target.classList.add("hidden"); event.target.parentElement.classList.remove("bg-blue-50"); }; </script> <div class="p-10 flex"> <div class="translate-x-0" draggable="true" ondragstart="dragStart(event, 'list')" ondrag="drag(event)" ondragend="dragEnd(event)"> <iframe class="pointer-events-none m-1 rounded" width="420" height="315" src="https://www.youtube.com/embed/tgbNymZ7vqY?&mute=1"> </iframe> </div> <div class="relative m-1 rounded border border-dashed border-blue-300 p-10 w-80 h-56" ondragenter="dragEnter(event)" ondragover="dragOver(event)" ondrop="drop(event)"> <div class="text-blue-300 font-bold text-center py-12">The Drop Zone</div> <div class="absolute inset-0 hidden" ondragleave="dragLeave(event)"></div> </div> </div>
It does interfere with playback, so it's a good idea to add this class during an "edit" mode, or whenever you need drag-and-drop to work.
If you use React, check out my reusable drag and drop component.