DEV Community

Syed Ali Reza Hussain
Syed Ali Reza Hussain

Posted on

Concept behind building a Draggable Modal

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Image description


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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Image description


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);
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

We also need to store the position of our mouse relative to the modal:

const [mousePosInsideModal, setMousePosInsideModal] = useState({
  x: 0,
  y: 0,
});
Enter fullscreen mode Exit fullscreen mode

We need to access the modal's DOM properties:

const modalRef = useRef<HTMLDivElement>(null);
Enter fullscreen mode Exit fullscreen mode

We assign this ref to Modal.Content Tag:

<Modal.Content ref={modalRef}>
  {/* children */}
</Modal.Content>
Enter fullscreen mode Exit fullscreen mode

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 */}
);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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.

Image description

const handleMouseMove = useCallback(
  (e: MouseEvent) => {
    const modalOffsetX = modalRef.current.offsetLeft;
    const modalOffsetY = modalRef.current.offsetTop;

    setPosition({
      x: e.clientX,
      y: e.clientY,
    });
  },
  [isDragging]
);
Enter fullscreen mode Exit fullscreen mode

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 the transform 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.

Image description

Image description


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 
Enter fullscreen mode Exit fullscreen mode
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]
);
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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 });
  }
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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! 🚧

Image description

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;
Enter fullscreen mode Exit fullscreen mode
  • minX and minY 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);
Enter fullscreen mode Exit fullscreen mode
  • maxX and maxY 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),
  };
});
Enter fullscreen mode Exit fullscreen mode
  • 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 within minX to maxX horizontally and minY to maxY 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)

Collapse
 
aflatoon2874 profile image
RSA

Thanks. Beautifully explained.