Building a Draggable Component 🛠️
Building a draggable component has always been a pain in the ass 😩 because every developer has their own special recipe behind building one. This article is not just another recipe 🧑🍳 but I’m also going to take you through the concept behind building this component.
Setup ⚙️
- Next.js
- Tailwind
- Mantine Library (only to use the Modal Component)
Yep, that’s all you need cuz we’re building this shit from scratch. I won’t take you through the entire setup process assuming that you already know it and have your project setup. But if you still need some help, you can always refer to the Next.js + Tailwind documentation.
Building the UI 🎨
Layout 📐
The UI is pretty straightforward. A single page with a button to trigger the modal. In this case, since we're using Mantine, we also need to wrap our page inside the MantineProvider
.
The MantineProvider
is needed because the modal and button components rely on its theme settings for styling and functionality. Even though you're using it for just these elements, it still needs to wrap them to ensure proper styling and behavior. However, since MantineProvider
is meant to be used only once at the root of the app, it's best to place it at a higher level rather than wrapping just individual components.
"use client";
import { Button, MantineProvider } from "@mantine/core";
import React, { useState } from "react";
import "@mantine/core/styles.css";
export default function Home() {
const [isOpen, setIsOpen] = useState(false);
return (
<MantineProvider>
<div
id="home"
className="relative w-screen h-screen !text-black flex flex-col gap-3 justify-center items-center bg-gray-50"
>
<Button
classNames={{
root: "!bg-black text-gray-50",
inner: "bg-black text-gray-50",
}}
className="bg-black text-gray-50"
onClick={() => setIsOpen(true)}
>
Open Modal
</Button>
</div>
</MantineProvider>
);
}
Modal 🪟
We're gonna be using the Mantine's Modal
Component here. I find it as one of the best modals because it offers full control throughout the component.
"use client";
import { Button, MantineProvider, Modal } from "@mantine/core";
import React, { useState } from "react";
import "@mantine/core/styles.css";
export default function Home() {
const [isOpen, setIsOpen] = useState(false);
return (
<MantineProvider>
<div
id="home"
className="relative w-screen h-screen !text-black flex flex-col gap-3 justify-center items-center bg-gray-50"
>
<Button
classNames={{
root: "!bg-black text-gray-50",
inner: "bg-black text-gray-50",
}}
className="bg-black text-gray-50"
onClick={() => setIsOpen(true)}
>
Open Modal
</Button>
<Modal.Root
classNames={{
content: "!min-h-[500px] !max-h-[500px] !transition-none",
}}
centered
size={400}
opened={isOpen}
onClose={() => setIsOpen(false)}
>
<Modal.Content>
<Modal.Header>
<Modal.Title>Modal title</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>Modal content</Modal.Body>
</Modal.Content>
</Modal.Root>
</div>
</MantineProvider>
);
}
Drag Functionality 🖱️
We have successfully made the UI for our project. So it's time for some head scratchers. 🤔
First, we need to track whether our modal is being dragged or not.
const [isDragging, setIsDragging] = useState(false);
We need to keep track of the modal’s position (both x and y) as it moves:
const [position, setPosition] = useState({ x: 0, y: 0 });
We also need to store the position of our mouse relative to the modal:
const [mousePosInsideModal, setMousePosInsideModal] = useState({
x: 0,
y: 0,
});
We need to access the modal's DOM properties:
const modalRef = useRef<HTMLDivElement>(null);
We assign this ref to Modal.Content
Tag:
<Modal.Content ref={modalRef}>
{/* children */}
</Modal.Content>
Mouse Down Function 🖱️
const handleMouseDown = (e: React.MouseEvent<HTMLElement>) => {
setIsDragging(true);
};
return (
{/* PARENT OPENING TAGS */}
<Modal.Content ref={modalRef}>
<Modal.Header onMouseDown={handleMouseDown}>
<Modal.Title>Modal title</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>Modal content</Modal.Body>
</Modal.Content>
{/* PARENT CLOSING TAGS */}
);
The mouse down function is pretty straightforward. As soon as you click on the modal, it sets the isDragging
state to true
. We pass this function as props to the onMouseDown
event listener of the Modal.Header
because that's what we're going to grab to move the modal.
But we also need to set this isDragging
state to false
every time we lift the mouse to stop dragging. That's when the handleMouseUp
function comes in.
This function is going to get triggered on the window's mouseup
event listener inside a useEffect
hook. Now you might be asking, why don't we pass the handleMouseUp
function to the Modal.Header
like we did earlier?
Well, that's because the mouseup
event needs to be global.
Example: You might start dragging the modal by clicking on its header, but as you move the mouse to drag it, the mouse might go outside the modal (e.g., if you drag it towards the top of the screen).
So, we need to detect the mouse release (mouseup
) no matter where the mouse is, even if it leaves the modal's area. This is why the handleMouseUp
event handler is attached to the window, not the modal.
const handleMouseUp = () => {
setIsDragging(false);
};
useEffect(() => {
if (isDragging) {
window.addEventListener("mouseup", handleMouseUp);
}
return () => {
if (isDragging) {
window.removeEventListener("mouseup", handleMouseUp);
}
};
}, [isDragging]);
Mouse Move Function 🖱️➡️
So we are handling the states for mousedown
and mouseup
events. Next up is mousemove
.
Now, I want to take you through this step-by-step. We'll first create a simple handleMouseMove
function, and this function will also get called on the window's mousemove
event listener.
We are also going to update the position
state that we created earlier every time the handleMouseMove
function is called.
const handleMouseMove = useCallback(
(e: MouseEvent) => {
setPosition({
x: e.clientX,
y: e.clientY,
});
},
[isDragging]
);
useEffect(() => {
if (isDragging) {
window.addEventListener("mouseup", handleMouseUp);
window.addEventListener("mousemove", handleMouseMove);
}
return () => {
if (isDragging) {
window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("mousemove", handleMouseMove);
}
};
}, [isDragging]);
Now, I want you to try dragging the modal. Did it work? No, right? The modal didn't move at all. That's because we are updating the position
state, but we're not using it anywhere.
We're going to use this position
values inside the modal's CSS transform
property. Every single time the position
state is updated, we update the transform
property.
useEffect(() => {
if (modalRef.current) {
modalRef.current.style.transform = `translate(${position.x}px, ${position.y}px)`;
}
}, [position]);
Now try dragging the modal. You'll see that the modal jumps to the bottom right corner of the screen. Now, that's when the concepts come into play.
I want you to go take a sip of water now because things are going to get a bit complex, but I'll try to explain it in the simplest way possible. 🚰
Understanding the Offset 📏
To make sure our modal moves accurately depending upon where the mouse is, we need to make some calculations, but first, let's understand why the above method didn't work.
Are you aware of the offsetLeft
property inside the DOM?
The
offsetLeft
property returns the left position (in pixels) relative to the parent.
In this case though, the parent is the viewport itself. We need to calculate the position of the element from the viewport. We can get that from the modalRef
that we created earlier.
const handleMouseMove = useCallback(
(e: MouseEvent) => {
const modalOffsetX = modalRef.current.offsetLeft;
const modalOffsetY = modalRef.current.offsetTop;
setPosition({
x: e.clientX,
y: e.clientY,
});
},
[isDragging]
);
When comparing the offsetLeft
and offsetTop
values with the transform
property of the modal, you’ll notice something interesting. Even though the modal’s offsetLeft
is 535px
, the translateX
value is 0px
.
This is because:
-
offsetLeft
measures the distance from the modal’s left edge to its parent’s left edge. -
transform: translate(0px, 0px)
means no additional movement from the modal’s current position—it stays exactly where it’s rendered based on the layout.
Now, looking at the image below:
- The text in red represents the mouse’s position relative to the viewport, not the parent element.
- If you directly apply these mouse coordinates as
transform
values, the modal will "jump" to a new position. That’s because thetransform
shifts the modal relative to its current position, while the mouse coordinates are absolute to the viewport. - To avoid this jump, you’d need to adjust the mouse coordinates relative to the modal's position.
Calculating Relative Mouse Position 🧮
To calculate the position of the mouse relative to the modal's position, we need to subtract the mouse position from the offsetLeft
value of the modal. Let's go ahead and add that to our handleMouseMove
function.
mouseXRelativeToModal = mouseXRelativeToViewport - modalOffsetLeft
mouseYRelativeToModal = mouseYRelativeToViewport - modalOffsetTop
const handleMouseMove = useCallback(
(e: MouseEvent) => {
const mouseX = e.clientX;
const mouseY = e.clientY;
const modalOffsetX = modalRef.current.offsetLeft;
const modalOffsetY = modalRef.current.offsetTop;
const newX = mouseX - modalOffsetX;
const newY = mouseY - modalOffsetY;
setPosition({
x: newX,
y: newY,
});
},
[isDragging]
);
Yet again, you'll find a glitch just when you start dragging the modal. This happens because when you click on the modal to start dragging, you might not always click exactly at the top-left corner. You could click anywhere inside the modal's header—like in the middle or towards the right. If we didn't account for this, the modal would jump so that its top-left corner snaps to where your mouse is, which would feel jarring.
How do you fix this? 🤔
Let's first define a state:
const [mousePosInsideModal, setMousePosInsideModal] = useState({
x: 0,
y: 0,
});
When you click (handleMouseDown
):
We calculate how far your mouse is from the modal's top-left corner using:
const handleMouseDown = (e: React.MouseEvent<HTMLElement>) => {
setIsDragging(true);
if (modalRef.current) {
const diffX = e.clientX - (modalRef.current.offsetLeft + position.x);
const diffY = e.clientY - (modalRef.current.offsetTop + position.y);
setMousePosInsideModal({ x: diffX, y: diffY });
}
};
These values (diffX
and diffY
) represent the exact spot inside the modal where you clicked.
We save this in mousePosInsideModal
so we can remember it while dragging.
When you move the mouse (handleMouseMove
):
We use mousePosInsideModal
to keep the distance between your mouse and the modal's top-left corner consistent while dragging:
const newX = mouseX - mousePosInsideModal.x - modalOffsetX;
const newY = mouseY - mousePosInsideModal.y - modalOffsetY;
This makes sure the modal moves smoothly without shifting position unexpectedly.
VOILA! It worked! 🎉
So, Is that it? Is there anything else? Of course, IT'S TIME TO SET SOME BOUNDARIES! 🚧
We need to make sure that our modal never leaves the viewport. When the modal is mounted:
Defining the minimum limits (top and left edges):
const minY = -modalOffsetY;
const minX = -modalOffsetX;
-
minX
andminY
define how far left and up the modal can be dragged. - Since the modal might not be starting from
(0,0)
, we use-modalOffsetX
and-modalOffsetY
to calculate how far it can go before it hits the edge. - This ensures you can't drag the modal beyond the top or left edges of the screen.
Defining the maximum limits (right and bottom edges):
const maxX = viewportWidth - (modalOffsetX + modalWidth);
const maxY = viewportHeight - (modalOffsetY + modalHeight);
-
maxX
andmaxY
define how far right and down the modal can go before it disappears off-screen. - It subtracts the modal’s size and its offset from the viewport size to calculate the limit.
- This ensures the modal stays fully visible within the screen's right and bottom edges.
Applying the limits when updating the position:
setPosition(() => {
return {
x: Math.min(Math.max(minX, newX), maxX),
y: Math.min(Math.max(minY, newY), maxY),
};
});
- This is where the actual clamping happens.
-
Math.max(minX, newX)
ensures that the new position doesn’t go beyond the minimum limits (left/top). -
Math.min(..., maxX)
ensures the position doesn’t exceed the maximum limits (right/bottom). - So, the modal’s position
(x, y)
will always stay withinminX
tomaxX
horizontally andminY
tomaxY
vertically.
Congratulations! 🎉
You just made your own draggable modal without using any external libraries. (Mantine is just a UI Library, for god's sake!).
I am a believer of building things from scratch as much as possible and not depending upon third-party libraries unless I really need to.
This is my first post on Dev.to and I'm excited to help you guys with more stuff like these so stay tuned! If you have any specific topics you’d like me to cover, questions about this post, or just want to share your feedback, feel free to leave a comment below. Your thoughts and ideas are always welcome. 💬
Happy Coding! 🚀
Top comments (1)
Thanks. Beautifully explained.