Drag and Drop
Back in the day, drag and drop games were the bomb. Whether you were decorating a space or playing dressup, these games were a staple of early websites with an artistic flair. As the web became more corporatized and hobbyist web design went the way of the dinosaur, these games have basically stopped existing. Let's make our own version.
Let's start by thinking about what we want to do. When we click on some image, we want to move the mouse and have the image move with it. We want the image's position to have something to do with the mouth's position. When we let go of the button, we want the image to stop moving and stay where it is. This suggests somethings we may want to look into:
- How to get the mouse's position
- How to set the image's position to the mouse's position
- How to know when the mouse is holding down vs up
- Managing when the image is draggable
Most of these can be handled using web events.
Moving a Single Image
Let's start with something straightforward. Make an HTML page with your image of choice. We don't want every image to be draggable, so we'll give it an id of 'draggable.'
<img src="https://i.imgur.com/7k0ViaO.png" height="40px" id="draggable" >
Now how are we going to move the image around? We don't usually move things when we are working in CSS, but if you think about it, we already have a way to manage where we want an element to be in a page: make its position: absolute
and then mark its position away from the edges of the browser. If we updated the image's left
and top
over time, then we would be able to see it move. Let's mark the position as absolute in the CSS. We'll also add some hover styling to make the edges of the image clear:
#draggable { position: absolute; } #draggable:hover { outline: 2px dashed #007bff; cursor: move; }
Let's get started with the Javascript. First, basic setup - fetch our image. We're using an id, so we don't need to filter the results. We also want some way to track whether the image is being moved or not, so let's add a boolean isDragging
.
let pants = document.getElementById("draggable"); let isDragging = false;
So, we want something to happen when we hold down on the image and move our mouse around. Let's look up the list of mouse events to see if any of these meet our needs.
click - Fired when a pointing device button (e.g., a mouse's primary button) is pressed and released on a single element.
mousedown - Fired when a pointing device button is pressed on an element.
mousemove - Fired when a pointing device (usually a mouse) is moved while over an element.
mouseup - Fired when a pointing device button is released on an element.
These all seem relevant. Let's think about our use case carefully - we want to click the mouse button, not release it, move the mouse, and then let go of the button. This suggests that we don't want to use click
because that one assumes you will press down and then release. We instead want to separate pressing and releasing. The two relevant events then are mousedown
, fired when you press down, and mouseup
, fired when you release the button. mousemove
seems like it might be useful for moving things around, so we will keep it in mind.
We can think of something simple: when we press the image, set isDragging
to true and when we release it, set isDragging
to false.
pants.addEventListener("mousedown", (event) => { isDragging = true; }); pants.addEventListener("mouseup", (event) => { isDragging = false; });
Groundbreaking stuff. Now let's think about the next part. When isDragging
is true, we want to change the position of the image to the position of the mouth. This is what creates dragging. How can we do that? We need to find a way to get the position of the mouse, for starters. Let us look up the Mouse Events Web API page. As we scroll through, we quickly see that these look promising:
MouseEvent.clientX - The X coordinate of the mouse pointer in viewport coordinates.
MouseEvent.clientY - The Y coordinate of the mouse pointer in viewport coordinates.
So we want to get the coordinates of the mouse as it is moving across the screen. It looks like the event we may want is mousemove
. Let's try something out:
pants.addEventListener("mousemove", (event) => { if (isDragging) { pants.style.top = event.clientY; pants.style.left = event.clientX; } });
OK, this is odd - every time we start to move the image, a ghost image appears instead and nothing happens. It seems there is some default behavior that causes this ghost image to appear, as if we were moving it to be copied somewhere else. We need to override it. It turns out this behavior is caused by the dragstart
event. We can override it thus:
pants.addEventListener("dragstart", (event) => { // prevent dragging behavior event.preventDefault(); });
No more ghost image! But nothing is happening. What's going on? Perhaps you've noticed the first error - we haven't given any units to the styling. Let's fix that up:
pants.addEventListener("mousemove", (event) => { if (isDragging) { pants.style.top = event.clientY + "px"; pants.style.left = event.clientX + "px"; } });
If you click on the image and move around, you will see that it moves! Success! But when you try to set it back down, you will find some odd behavior. It will stick to the mouse even if you are not dragging it. You may click multiple times and find that it won't set isDragging
to false.
The reason for this is that we are only listening to mousemove
when the mouse is over the pants. It is possible to move the mouse so that it exits the bounds of the image. Since mousemove
is only firing on the pants image, you won't see the image continue to move. However, mouseup
also only fires on the pants image. This means when you try to release the mouse, if you are not on top of the image, you will not set isDragging
to false. This causes unwanted behavior where moving the mouse over the image will make it move again even if you are not dragging the image, or it unexpectedly stops dragging.
We need to rethink what we are doing. We want to move the mouse all over the screen. As such, it makes more sense to attach mousemove
to something much bigger. We can attach it to the window object instead. Instead of listening to when the mouse moves on pants, we are going to listen for it on window. So replace the pants mousemove event listener with this:
addEventListener("mousemove", (event) => { if (isDragging) { pants.style.top = event.clientY + "px"; pants.style.left = event.clientX + "px"; } });
We can now drag the pants wherever we want. Success! Just one minor detail before we move on into adding this feature to more items. Notice that every time you grab the pants, regardless of where the mouse is on the pants, the pants will snap to be to the bottom and right of the mouse. This is not a big deal with such small pants, but if the item is bigger, it can be quite noticeable and ugly. It would be nice to make it so that you can grab the pants and the pants stay in the same position that they were when the mouse grabbed it.
The reason for this is we are assigning the pants' position to be where the mousemove event is. So the top and left of the image will align there. In order to fix it, we will need to find a way to make it so the image is moved but with an offset based on where the mouse is on the image when you clicked it.
Near where we declared the pant variables, let's add some variables for this offset.
let offsetX = 0, offsetY = 0;
Let's alter the mousedown
event. When we click the pants, we're going to get the size of the pants using the getBoundingClientRect()
method. We will get the difference between the position of the mouse click and the position of the pants. We save that to the offsets, which we will use later.
pants.addEventListener("mousedown", (event) => { isDragging = true; const rect = pants.getBoundingClientRect(); offsetX = event.clientX - rect.left; offsetY = event.clientY - rect.top; // note that the above is not the moving logic // these offsets will stay the same as long as you are moving the pants });
Return to the mousemove
event. Let's subtract the offset from the event positions so that the pants don't snap to the top left.
addEventListener("mousemove", (event) => { if (isDragging) { pants.style.left = event.clientX - offsetX + "px"; pants.style.top = event.clientY - offsetY + "px"; } });
Alright, we now have the image moving logic fully working! Here is everything we have so far for the JS, with comments:
let pants = document.getElementById("draggable"); let offsetX = 0, offsetY = 0; let isDragging = false; pants.addEventListener("mousedown", (event) => { isDragging = true; // we don't want the pants' top left to snap to where the mouse clicked // we want the pants to stay where they are // so we update the offsets so that the pants move relative to where inside the pants you clicked them const rect = pants.getBoundingClientRect(); offsetX = event.clientX - rect.left; offsetY = event.clientY - rect.top; // note that the above is not the moving logic // these offsets will stay the same as long as you are moving the pants }); pants.addEventListener("mouseup", (event) => { isDragging = false; }); pants.addEventListener("dragstart", (event) => { // prevent dragging behavior event.preventDefault(); }); // this event listener needs to be over the container, not the current item, // because otherwise it will stop firing when you move the mouse past the item. // it also means that the mouseup event won't fire properly because the mouse won't be on the item anymore addEventListener("mousemove", (event) => { if (isDragging) { pants.style.left = event.clientX - offsetX + "px"; pants.style.top = event.clientY - offsetY + "px"; } });
Moving multiple images
Right now we can only move one image, because IDs can only appear on one item at once. To update our logic to let us move multiple images, we need to make draggable
into a class instead of an id. We will also need to update the code to move any draggable
image instead of the one hardcoded one.
Let's change our HTML and just duplicate our existing image but move it down a little.
<div id="container"> <img src="https://i.imgur.com/7k0ViaO.png" height="40px" class="draggable" > <img src="https://i.imgur.com/7k0ViaO.png" height="40px" style="top:70px" class="draggable" > </div>
We will fetch all the draggable items. Add this at the top of the JS:
const draggables = document.querySelectorAll(".draggable");
We will now change our approach. We will loop through each of the draggables and add an event listener to that draggable item. In this way, we can use the same logic we had before, but applied to multiple items. Let's move all our pants
related logic inside the foreach and rename it to item
.
document.querySelectorAll(".draggable").forEach((item) => { item.addEventListener("mousedown", (event) => { isDragging = true; const rect = item.getBoundingClientRect(); offsetX = event.clientX - rect.left; offsetY = event.clientY - rect.top; }); item.addEventListener("mouseup", (event) => { isDragging = false; }); item.addEventListener("dragstart", (event) => { // prevent dragging behavior event.preventDefault(); }); });
This looks good, but if you try it out, it will not work. Why? Because this guy down here is still looking for a pants
reference:
addEventListener("mousemove", (event) => { if (isDragging) { pants.style.left = event.clientX - offsetX + "px"; pants.style.top = event.clientY - offsetY + "px"; } });
This is a bit of a bind because we want this event listener to be on the window, so we can't add it inside the forEach. So we need some way for this event listener to know what is the current item being moved. The solution, then, is to add a global variable informing us what item is currently being moved. Let's add this at the top:
var currentItem = null;
We will set the currentItem
when we click it and we will remove it when we release the mouse.
document.querySelectorAll(".draggable").forEach((item) => { item.addEventListener("mousedown", (event) => { isDragging = true; currentItem = event.target; const rect = item.getBoundingClientRect(); offsetX = event.clientX - rect.left; offsetY = event.clientY - rect.top; }); item.addEventListener("mouseup", (event) => { isDragging = false; currentItem = null; }); item.addEventListener("dragstart", (event) => { // prevent dragging behavior event.preventDefault(); }); });
And we will also update the pants
reference to currentItem
.
addEventListener("mousemove", (event) => { if (isDragging) { currentItem.style.left = event.clientX - offsetX + "px"; currentItem.style.top = event.clientY - offsetY + "px"; } });
This should move each item around! Now let's do some aesthetic fixes. First, notice if you grab the small item and move it over the big item, the hover styling applies to the big item. We only want the current item to have hover styling. Also, the small item will always be behind the bigger item. What if we want the small item on top? We can fix this by adding this:
item.addEventListener("mousedown", (event) => { isDragging = true; currentItem = event.target; // the last item you selected is on top document.body.appendChild(currentItem); const rect = item.getBoundingClientRect(); offsetX = event.clientX - rect.left; offsetY = event.clientY - rect.top; });
This appends the item to the top layer of the body. It will thus never trigger the hover condition for a different item. As a bonus, the last selected item will always appear on top, making it easier to layer.
I also want to change the cursor so that it's a grabby hand on hover and a grabbing hand while moving.
item.addEventListener("mousedown", (event) => { isDragging = true; currentItem = event.target; currentItem.style.cursor = "grabbing"; // the last item you selected is on top document.body.appendChild(currentItem); const rect = item.getBoundingClientRect(); offsetX = event.clientX - rect.left; offsetY = event.clientY - rect.top; }); item.addEventListener("mouseup", (event) => { if (currentItem) { currentItem.style.cursor = "grab"; } isDragging = false; currentItem = null; });
Nice! We can now easily tag items to be movable using the draggable
class. You can add a background image of a map or a character and then add draggable
towns, clothes, etc. Be as creative as you would like. The final Javascript:
const draggables = document.querySelectorAll(".draggable"); let currentItem = null; let offsetX = 0, offsetY = 0; let isDragging = false; draggables.forEach((item) => { item.addEventListener("mousedown", (event) => { isDragging = true; currentItem = event.target; currentItem.style.cursor = "grabbing"; document.body.appendChild(currentItem); const rect = item.getBoundingClientRect(); offsetX = event.clientX - rect.left; offsetY = event.clientY - rect.top; }); item.addEventListener("mouseup", (event) => { if (currentItem) { currentItem.style.cursor = "grab"; } isDragging = false; currentItem = null; }); item.addEventListener("dragstart", (event) => { // prevent dragging behavior event.preventDefault(); }); }); addEventListener("mousemove", (event) => { if (isDragging) { currentItem.style.left = event.clientX - offsetX + "px"; currentItem.style.top = event.clientY - offsetY + "px"; } });
This is all it takes to get started with a drag and drop dressup game. The possibilities are endless. Why not create a "closet" feature that lets you swap between different items? Or why not make it so dragging creates a copy of the item? Maybe you can implement a trash can feature to remove extraneous items? From here on out, it's up to you.