Drag functionality

The content so far has been rather abstract and philosophical. In this post, we're going to get our hands dirty and implement drag functionality on a popup. We'll apply ideas from the previous post on points and vectors to figure out the math.

Here's the basic code we'll be starting with.

Initial code

import {useId} from "react";

export default function Demo() {
  return (
    <Popup title="Demo">
      Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
      tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
      veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
      commodo consequat. Duis aute irure dolor in reprehenderit in voluptate
      velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
      cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
      est laborum.
    </Popup>
  );
}

/** Draggable popup */
function Popup({
  children,
  title,
}: {
  children: React.ReactNode;
  title: React.ReactNode;
}) {
  const headerId = useId();

  return (
    <aside
      aria-labelledby={headerId}
      className="w-[600px] max-w-[100vw] rounded-md border border-solid border-gray-200 shadow dark:border-gray-700"
      role="dialog"
    >
      <header
        className="select-none rounded-t-md bg-purple-600 px-2 py-1 text-white"
        id={headerId}
      >
        {title}
      </header>
      <div className="px-2 py-1">{children}</div>
    </aside>
  );
}

We want to add the ability to drag this popup while holding onto the title bar.

This is a pretty simple component; the only interesting thing to point out is the use of the dialog role and the aria-labelledby attribute, which are used for accessibility. These aren't relevant to the mathematical content of what we're doing, but I'm including them anyway because accessibility gets skipped over far too often.

A basic attempt

Before getting into the vector math, we'll start by wiring up the event listeners. I'm going to intentionally make several errors in order to point them out / warn you about them.

onPointerMove

To respond to mouse movement, we use the pointermove event. Let's try adding that to the title bar:

import {useId, useRef} from "react";

export default function Demo() {
  return (
    <Popup title="Demo">
      Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
      tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
      veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
      commodo consequat. Duis aute irure dolor in reprehenderit in voluptate
      velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
      cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
      est laborum.
    </Popup>
  );
}

/** Draggable popup */
function Popup({
  children,
  title,
}: {
  children: React.ReactNode;
  title: React.ReactNode;
}) {
  const headerId = useId();

  const onPointerMove: React.PointerEventHandler = (e) => {
    console.log(e.clientX, e.clientY);
  };

  return (
    <aside
      aria-labelledby={headerId}
      className="absolute w-[600px] max-w-[100vw] rounded-md border border-solid border-gray-200 shadow dark:border-gray-700"
      role="dialog"
    >
      <header
        className="select-none rounded-t-md bg-purple-600 px-2 py-1 text-white"
        id={headerId}
        onPointerMove={onPointerMove}
      >
        {title}
      </header>
      <div className="px-2 py-1">{children}</div>
    </aside>
  );
}

Open up your browser console to see the results. We're getting the (x,y)(x,y) coordinates of the pointer (cursor/finger/stylus), but only when we're over the title bar. When we try to drag the dialog into a new position, the pointer will come off of the title bar.

Body event handlers

To fix this, we'll add the pointermove event on document.body. This needs to be done imperatively, not through a React prop. We'll add a pointerdown event to the title bar, and that will set up the necessary subscriptions. We can also get it to transate the dialog now.

import {useId, useRef} from "react";

export default function Demo() {
  return (
    <Popup title="Demo">
      Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
      tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
      veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
      commodo consequat. Duis aute irure dolor in reprehenderit in voluptate
      velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
      cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
      est laborum.
    </Popup>
  );
}

/** Draggable popup */
function Popup({
  children,
  title,
}: {
  children: React.ReactNode;
  title: React.ReactNode;
}) {
  const headerId = useId();

  const ref = useRef<HTMLElement>(null);

  const onPointerMove = (e: PointerEvent) => {
    if (!ref.current) return;
    ref.current.style.translate = `${e.clientX}px ${e.clientY}px`;
  };

  const onPointerDown: React.PointerEventHandler = (e) => {
    document.body.addEventListener("pointermove", onPointerMove);
    document.body.addEventListener("pointerup", onPointerUp);
  };

  const onPointerUp: React.PointerEventHandler = (e) => {
    document.body.removeEventListener("pointermove", onPointerMove);
    document.body.removeEventListener("pointerup", onPointerUp);
  };

  return (
    <aside
      aria-labelledby={headerId}
      className="absolute w-[600px] max-w-[100vw] rounded-md border border-solid border-gray-200 shadow dark:border-gray-700"
      ref={ref}
      role="dialog"
    >
      <header
        className="select-none rounded-t-md bg-purple-600 px-2 py-1 text-white"
        id={headerId}
        onPointerDown={onPointerDown}
        onPointerUp={onPointerUp}
      >
        {title}
      </header>
      <div className="px-2 py-1">{children}</div>
    </aside>
  );
}

If you try this, you'll notice several issues. Take a minute to try diagnosing (and fixing) them yourself before looking at the solution.

  • the top-left corner of the dialog is following the pointer position, rather than the pointer staying in the same place as when you pressed down initially. We'll fix this in the next section.

  • the onPointerUp event doesn't seem to be working, and the dialog can only be "pushed" down and right.
    Hint 1: use the Inspector.
    Hint 2: this is unlikely to occur in a real-world application.

  • after fixing the above issue, notice that when you drag your pointer outside of the preview window and release it, you're still dragging when you move your pointer back into the preview window.

Here's the code with fixes for the latter two issues. Don't peek until you've tried diagnosing+fixing them yourself!

import {useId, useRef} from "react";

export default function Demo() {
  return (
    <main className="min-w-screen min-h-screen">
      <Popup title="Demo">
        Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
        tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
        veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
        commodo consequat. Duis aute irure dolor in reprehenderit in voluptate
        velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
        occaecat cupidatat non proident, sunt in culpa qui officia deserunt
        mollit anim id est laborum.
      </Popup>
    </main>
  );
}

/** Draggable popup */
function Popup({
  children,
  title,
}: {
  children: React.ReactNode;
  title: React.ReactNode;
}) {
  const headerId = useId();

  const ref = useRef<HTMLElement>(null);

  const onPointerMove = (e: PointerEvent) => {
    if (!ref.current) return;
    ref.current.style.translate = `${e.clientX}px ${e.clientY}px`;
  };

  const endDrag = () => {
    document.body.removeEventListener("pointerleave", endDrag);
    document.body.removeEventListener("pointermove", onPointerMove);
    document.body.removeEventListener("pointerup", endDrag);
  };

  const onPointerDown = () => {
    document.body.addEventListener("pointerleave", endDrag);
    document.body.addEventListener("pointermove", onPointerMove);
    document.body.addEventListener("pointerup", endDrag);
  };

  return (
    <aside
      aria-labelledby={headerId}
      className="absolute w-[600px] max-w-[100vw] rounded-md border border-solid border-gray-200 shadow dark:border-gray-700"
      ref={ref}
      role="dialog"
    >
      <header
        className="select-none rounded-t-md bg-purple-600 px-2 py-1 text-white"
        id={headerId}
        onPointerDown={onPointerDown}
      >
        {title}
      </header>
      <div className="px-2 py-1">{children}</div>
    </aside>
  );
}

Figuring out the math

To figure out how to position the popup correctly, let's introduce some notation. Let

  • tRt\in\R denote the time since the pointerdown event
  • p(t)A2p(t)\in \AffR 2 denote the coordinates of the pointer at time tt
  • c(t)A2c(t)\in\AffR 2 denote the desired coordinates of the top-left corner of the popup at time tt

To keep the pointer "in the same place" on the title bar, the relationship we want is

p(0)c(0)=p(t)c(t)for all tp(0) - c(0) = p(t) - c(t) \quad\text{for all }t

Note that c(t)c(t) and p(t)p(t) are points, but the common difference here is a vector.

Which of these do we know?

  • we get p(t)p(t) from e.clientX and e.clientY. For t=0,t=0, e is the pointerdown event, for t>0t>0 it's the pointermove event.

  • we can get c(0)c(0) from getBoundingClientRect() on the popup ref

  • what we need to calculate is c(t)c(t) for t>0t>0

We can rearrange the above formula to isolate what we want to calculate:

c(t)=p(t)+(c(0)p(0))offsetc(t) = p(t) + \underbrace{(c(0) - p(0))}_{\mathtt{offset}}

Here's the final code:

import {useId, useRef} from "react";

export default function Demo() {
  return (
    <main className="min-w-screen min-h-screen">
      <Popup title="Demo">
        Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
        tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
        veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
        commodo consequat. Duis aute irure dolor in reprehenderit in voluptate
        velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
        occaecat cupidatat non proident, sunt in culpa qui officia deserunt
        mollit anim id est laborum.
      </Popup>
    </main>
  );
}

/** Draggable popup */
function Popup({
  children,
  title,
}: {
  children: React.ReactNode;
  title: React.ReactNode;
}) {
  const headerId = useId();

  const ref = useRef<HTMLElement>(null);

  const offset = useRef({x: 0, y: 0});

  const onPointerMove = (e: PointerEvent) => {
    if (!ref.current) return;
    ref.current.style.translate = `${e.clientX + offset.current.x}px ${e.clientY + offset.current.y}px`;
  };

  const endDrag = () => {
    document.body.removeEventListener("pointerleave", endDrag);
    document.body.removeEventListener("pointermove", onPointerMove);
    document.body.removeEventListener("pointerup", endDrag);
  };

  const onPointerDown: React.PointerEventHandler = (e) => {
    if (!ref.current) return;

    // record offset
    // [rect.x, rect.y] is what we called c(0) in the formulas
    // [e.clientX, e.clientY] is what we called p(0) in the formulas
    const rect = ref.current.getBoundingClientRect();
    offset.current = {x: rect.x - e.clientX, y: rect.y - e.clientY};

    // attach event handlers
    document.body.addEventListener("pointerleave", endDrag);
    document.body.addEventListener("pointermove", onPointerMove);
    document.body.addEventListener("pointerup", endDrag);
  };

  return (
    <aside
      aria-labelledby={headerId}
      className="absolute w-[600px] max-w-[100vw] rounded-md border border-solid border-gray-200 shadow dark:border-gray-700"
      ref={ref}
      role="dialog"
    >
      <header
        className="select-none rounded-t-md bg-purple-600 px-2 py-1 text-white"
        id={headerId}
        onPointerDown={onPointerDown}
      >
        {title}
      </header>
      <div className="px-2 py-1">{children}</div>
    </aside>
  );
}

Extracting Hooks

Now that we have the functionality in place, let's refactor things a little bit to split out logic that's not specific to our app.

First let's extract the logic of starting a drag operation on pointerdown, and ending it on pointerup or pointerleave. It's conceivable that a consumer will want to distinguish between the latter two events, so we'll make them separate.

import {useCallback} from "react";

/**
 * Provides generic drag functionality
 * @returns An object of event handlers to spread onto the target
 */
function useDrag<T = Element>(opts: {
  down?: React.PointerEventHandler<T>;
  leave?: (e: PointerEvent) => unknown;
  move: (e: PointerEvent) => unknown;
  up?: (e: PointerEvent) => unknown;
}) {
  // pointer event handlers
  const onPointerUp = useCallback(
    (e: PointerEvent) => {
      opts.up?.(e);
      unsubscribe();
    },
    [opts.up],
  );

  const onPointerLeave = useCallback(
    (e: PointerEvent) => {
      opts.leave?.(e);
      unsubscribe();
    },
    [opts.leave],
  );

  /** Remove event handlers from document.body */
  const unsubscribe = useCallback(() => {
    document.body.removeEventListener("pointerleave", onPointerLeave);
    document.body.removeEventListener("pointermove", opts.move);
    document.body.removeEventListener("pointerup", onPointerUp);
  }, [opts.move, onPointerLeave, onPointerUp]);

  const onPointerDown: React.PointerEventHandler<T> = useCallback(
    (e) => {
      opts.down?.(e);

      // attach event handlers
      document.body.addEventListener("pointerleave", onPointerLeave);
      document.body.addEventListener("pointermove", opts.move);
      document.body.addEventListener("pointerup", onPointerUp);
    },
    [opts.move, onPointerUp, onPointerLeave],
  );

  return {onPointerDown};
}

Next, let's address the specific case of dragging an element. We can build on what we did above:

/** Provides functionality to drag an element */
function useDragElement<TRoot extends HTMLElement, TAnchor extends Element>(): {
  /** Events to spread onto the dragging "anchor" */
  anchorEvents: {
    onPointerDown: React.PointerEventHandler<TAnchor>;
  };

  /** Ref to attach to the object you wish to make draggable. */
  ref: React.RefObject<TRoot>;
} {
  const ref = useRef<TRoot>(null);

  const offset = useRef({x: 0, y: 0});

  const anchorEvents = useDrag(
    useMemo(
      () => ({
        down: (e: React.PointerEvent<TAnchor>) => {
          if (!ref.current) return;

          // set offset
          const rect = ref.current.getBoundingClientRect();
          offset.current = {x: rect.x - e.clientX, y: rect.y - e.clientY};
        },
        move: (e: PointerEvent) => {
          if (!ref.current) return;
          const x = e.clientX + offset.current.x;
          const y = e.clientY + offset.current.y;
          ref.current.style.translate = `${x}px ${y}px`;
        },
      }),
      [],
    ),
  );

  return {
    anchorEvents,
    ref,
  };
}

The complete refactored version is at the GitHub link below.

Access source on GitHubSource

Remarks

To get a full-featured dialog component (accessibility, focus guard, etc.), I recommend the Radix Dialog component. You can attach this behavior onto one of those.

Did we really need the abstract math perspective to figure the formula for the translation? Not really, we'd have eventually gotten there by trial and error + drawing some pictures. But getting comfortable with manipulating these makes it easier to just sit down and derive the formula and feel confident in it. The "type safety" of the points-vs-vectors distinction can also rule out faulty guesses.