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
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:
Open up your browser console to see the results. We're getting the 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.
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
onPointerUpevent 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!
Figuring out the math
To figure out how to position the popup correctly, let's introduce some notation. Let
- denote the time since the
pointerdownevent - denote the coordinates of the pointer at time
- denote the desired coordinates of the top-left corner of the popup at time
To keep the pointer "in the same place" on the title bar, the relationship we want is
Note that and are points, but the common difference here is a vector.
Which of these do we know?
-
we get from
e.clientXande.clientY. For ,eis thepointerdownevent, for it's thepointermoveevent. -
we can get from
getBoundingClientRect()on the popupref -
what we need to calculate is for
We can rearrange the above formula to isolate what we want to calculate:
Here's the final code:
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.
Remarks
To get a full-featured dialog component (accessibility, focus guard, etc.), I recommend the Base UI 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.
Exercises
-
Modify the code to prevent the user from dragging the popup offscreen. You will need
window.innerWidthandwindow.innerHeight. -
Add functionality to resize the popup. You can make drag handles like so: