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 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
- denote the time since the
pointerdown
event - 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.clientX
ande.clientY
. Fore
is thepointerdown
event, for it's thepointermove
event. -
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:
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.
SourceRemarks
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.