Why This Implementation Matters
- Smooth animation: render updates with requestAnimationFrame
- Stays attached during fast movement with setPointerCapture
- Keeps working even while scrolling thanks to the scroll event handler
- Simple cleanup by listening for lostpointercapture and calling releasePointerCapture
Because I apply touch-action: none
for touch devices, make sure you copy the CSS from the CodePen demo when you reuse this.
Code
const target = document.querySelector('.draggable') as HTMLElement
const startScroll = { x: 0, y: 0 }
const currentScroll = { x: 0, y: 0 }
const originPosition = { x: 0, y: 0 }
const startPosition = { x: 0, y: 0 }
const currentPosition = { x: 0, y: 0 }
let isDragging = false
let rafId: null | number = null
const calculatePosition = () => {
return {
x: originPosition.x + currentPosition.x - startPosition.x + currentScroll.x - startScroll.x,
y: originPosition.y + currentPosition.y - startPosition.y + currentScroll.y - startScroll.y,
}
}
const updatePosition = () => {
const { x, y } = calculatePosition()
target.style.transform = `translate3d(${x}px, ${y}px, 0)`
rafId = null
}
const requestUpdate = () => {
if (!rafId) {
rafId = requestAnimationFrame(updatePosition)
}
}
const handleScroll = () => {
currentScroll.x = window.scrollX
currentScroll.y = window.scrollY
requestUpdate()
}
const handlePointerDown = (e: PointerEvent) => {
if (!e.isPrimary || isDragging) {
return
}
isDragging = true
startPosition.x = currentPosition.x = e.clientX
startPosition.y = currentPosition.y = e.clientY
startScroll.x = currentScroll.x = window.scrollX
startScroll.y = currentScroll.y = window.scrollY
target.setPointerCapture(e.pointerId)
target.classList.add('is-dragging')
window.addEventListener('scroll', handleScroll, { passive: true })
target.addEventListener('pointermove', handlePointerMove, { passive: true })
}
const handlePointerMove = (e: PointerEvent) => {
if (!e.isPrimary || !isDragging) {
return
}
currentPosition.x = e.clientX
currentPosition.y = e.clientY
requestUpdate()
}
const cleanup = (e: PointerEvent) => {
if (!e.isPrimary || !isDragging) {
return
}
isDragging = false
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
const { x, y } = calculatePosition()
originPosition.x = x
originPosition.y = y
target.classList.remove('is-dragging')
window.removeEventListener('scroll', handleScroll)
target.removeEventListener('pointermove', handlePointerMove)
}
const release = (e: PointerEvent) => {
target.releasePointerCapture(e.pointerId)
}
target.addEventListener('pointerdown', handlePointerDown, { passive: true })
target.addEventListener('pointerup', release)
target.addEventListener('pointercancel', release)
target.addEventListener('lostpointercapture', cleanup)
React
component
import { useDrag } from './useDrag'
const DragExample = () => {
const { ref, isDragging, transform } = useDrag<HTMLDivElement>()
return (
<div className="stage">
<div
ref={ref}
className={`draggable${isDragging ? ' is-dragging' : ''}`}
style={{ transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` }}
/>
</div>
)
}
hooks
import { useEffect, useRef, useState } from 'react'
type Position = {
x: number
y: number
}
type ScrollState = {
start: Position
current: Position
}
type PointerPositionState = {
origin: Position
start: Position
current: Position
}
export const useDrag = <T extends HTMLElement = HTMLElement>() => {
const targetRef = useRef<T | null>(null)
const scrollRef = useRef<ScrollState>({
start: { x: 0, y: 0 },
current: { x: 0, y: 0 }
})
const positionRef = useRef<PointerPositionState>({
origin: { x: 0, y: 0 },
start: { x: 0, y: 0 },
current: { x: 0, y: 0 }
})
const rafIdRef = useRef<number | null>(null)
const isDraggingRef = useRef(false)
const [transform, setTransform] = useState<Position>({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
useEffect(() => {
const node = targetRef.current
if (!node) {
return
}
const calculatePosition = (): Position => {
const scroll = scrollRef.current
const pointer = positionRef.current
return {
x: pointer.origin.x + pointer.current.x - pointer.start.x + scroll.current.x - scroll.start.x,
y: pointer.origin.y + pointer.current.y - pointer.start.y + scroll.current.y - scroll.start.y
}
}
const updatePosition = () => {
rafIdRef.current = null
setTransform(calculatePosition())
}
const requestUpdate = () => {
if (!rafIdRef.current) {
rafIdRef.current = window.requestAnimationFrame(updatePosition)
}
}
const handleScroll = () => {
const scroll = scrollRef.current
scroll.current.x = window.scrollX
scroll.current.y = window.scrollY
requestUpdate()
}
const handlePointerDown = (event: PointerEvent) => {
if (!event.isPrimary || isDraggingRef.current) {
return
}
isDraggingRef.current = true
setIsDragging(true)
const pointer = positionRef.current
const scroll = scrollRef.current
pointer.start.x = pointer.current.x = event.clientX
pointer.start.y = pointer.current.y = event.clientY
scroll.start.x = scroll.current.x = window.scrollX
scroll.start.y = scroll.current.y = window.scrollY
node.setPointerCapture(event.pointerId)
window.addEventListener('scroll', handleScroll, { passive: true })
node.addEventListener('pointermove', handlePointerMove, { passive: true })
}
const handlePointerMove = (event: PointerEvent) => {
if (!event.isPrimary || !isDraggingRef.current) {
return
}
const pointer = positionRef.current
pointer.current.x = event.clientX
pointer.current.y = event.clientY
requestUpdate()
}
const cleanup = (event: PointerEvent) => {
if (!event.isPrimary || !isDraggingRef.current) {
return
}
isDraggingRef.current = false
setIsDragging(false)
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = null
}
const nextPosition = calculatePosition()
const pointer = positionRef.current
pointer.origin.x = nextPosition.x
pointer.origin.y = nextPosition.y
setTransform(nextPosition)
window.removeEventListener('scroll', handleScroll)
node.removeEventListener('pointermove', handlePointerMove)
}
const releasePointer = (event: PointerEvent) => {
if (!event.isPrimary) {
return
}
node.releasePointerCapture(event.pointerId)
}
node.addEventListener('pointerdown', handlePointerDown, { passive: true })
node.addEventListener('pointerup', releasePointer)
node.addEventListener('pointercancel', releasePointer)
node.addEventListener('lostpointercapture', cleanup)
return () => {
node.removeEventListener('pointerdown', handlePointerDown)
node.removeEventListener('pointerup', releasePointer)
node.removeEventListener('pointercancel', releasePointer)
node.removeEventListener('lostpointercapture', cleanup)
window.removeEventListener('scroll', handleScroll)
node.removeEventListener('pointermove', handlePointerMove)
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = null
}
isDraggingRef.current = false
}
}, [])
return {
ref: targetRef,
isDragging,
transform
}
}
I do not work with React very often, so I am not entirely sure how idiomatic this hook is.
Pointer Events
Pointer Events unify different input devices such as mouse, pen, and touch under a single set of events.
PointerEvent interface
interface PointerEvent : MouseEvent {
constructor(DOMString type, optional PointerEventInit eventInitDict = {});
readonly attribute long pointerId;
readonly attribute double width;
readonly attribute double height;
readonly attribute float pressure;
readonly attribute float tangentialPressure;
readonly attribute long tiltX;
readonly attribute long tiltY;
readonly attribute long twist;
readonly attribute double altitudeAngle;
readonly attribute double azimuthAngle;
readonly attribute DOMString pointerType;
readonly attribute boolean isPrimary;
[SecureContext] sequence<PointerEvent> getCoalescedEvents();
sequence<PointerEvent> getPredictedEvents();
}
The spec looks like this, so think of Pointer Events as a modernized MouseEvent. Properties such as isPrimary and pointerId are especially useful when working with pointer capture.
Key Events
You still get the familiar MouseEvent events, so here are the ones unique to Pointer Events. I love that lostpointercapture
fires at the end. The standard flow is down → move → up → lost.
Event name | Summary |
---|---|
pointercancel | Fired when the pointer event is cancelled |
gotpointercapture | Fired when the pointer is captured |
lostpointercapture | Fired when the pointer is released |
Pointer Capture
This is the feature that makes Pointer Events so convenient. Calling setPointerCapture() on an element ties the pointer to that element. With mouse events you could lose the drag during fast movement and had to bind mousemove
to a larger element. Pointer capture removes that requirement.
You can explicitly release it with releasePointerCapture().
Implicit Release of Pointer Capture
Immediately after firing the pointerup or pointercancel events, the user agent MUST clear the pending pointer capture target override for the pointerId of the pointerup or pointercancel event that was just dispatched, and then run process pending pointer capture steps to fire lostpointercapture if necessary.
The spec states that immediately after pointerup
or pointercancel
, the browser must clear the capture override, which triggers lostpointercapture
if needed.
So even if you do not call releasePointerCapture()
manually, the pointer is released implicitly and lostpointercapture
will fire—probably. Even knowing that, I still want to release it myself.
const cleanup = (e: PointerEvent) => {}
const release = (e: PointerEvent) => {
target.releasePointerCapture(e.pointerId)
}
target.addEventListener('pointerup', release)
target.addEventListener('pointercancel', release)
target.addEventListener('lostpointercapture', cleanup)
Pairing pointerdown
with pointerup
just feels right.
How movementX/Y Behaves
This is a bit of a tangent, but I spent time digging into movementX/Y
. In theory it is convenient because it gives you the delta from the previous position. However, the value is returned as an integer, so I worried that rounding sub-pixel movement and accumulating it would introduce drift. I ended up sticking to the classic approach of subtracting from the origin.
// console.log
PointerEvent:clientX 340.7578125
MouseEvent:clientX 340
MouseEvent:movementX -3
Looking at Chromium’s implementation, MouseEvent
truncates clientX/Y
to integers (the type is double
), and movementX/Y
is also handled as an integer. My C++ skills are nonexistent, so I am mostly reading the vibes.
▼ third_party/blink/renderer/core/events/mouse_event.h
virtual double clientX() const { return std::floor(client_x_); }
virtual double clientY() const { return std::floor(client_y_); }
int movementX() const { return movement_delta_.x(); }
int movementY() const { return movement_delta_.y(); }
On the other hand, PointerEvent seems to return floating point values unless it is a click-type event.
▼ third_party/blink/renderer/core/events/pointer_event.h
double clientX() const override {
if (ShouldHaveIntegerCoordinates())
return MouseEvent::clientX();
return client_x_;
}
▼ third_party/blink/renderer/core/events/pointer_event.cc
bool PointerEvent::ShouldHaveIntegerCoordinates() const {
if (type() == event_type_names::kClick ||
type() == event_type_names::kContextmenu ||
type() == event_type_names::kAuxclick) {
return true;
}
return false;
}
Testing confirms that behavior.
pointermove: 396.28515625
click: 396
Event | Value |
---|---|
MouseEvent | Integer |
PointerEvent | Floating point |
Even if rounding introduces error, repeated calls might balance out, so it may not be a big deal. Comparing both approaches only showed about a one-pixel difference.
client: { x: 248.48828125, y: 323.94140625 }
movement: { x: 248, y: 325 }
Warning: Browsers use different units for movementX and screenX than what the specification defines. Depending on the browser and operating system, the movementX units may be a physical pixel, a logical pixel, or a CSS pixel.
I have not verified this warning, but MDN calls it out so I am leaving it here.
This turned into a long digression, but if you care about the tiny details, maybe avoid movementX/Y
. That is about as firm a conclusion as I can give.
Top comments (0)