DEV Community

nishinoshake
nishinoshake

Posted on

Smooth Drag Interactions with Pointer Events

Why This Implementation Matters

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

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

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

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

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.

9.5 Implicit release of pointer capture

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

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

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

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

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

Testing confirms that behavior.

pointermove: 396.28515625
click: 396
Enter fullscreen mode Exit fullscreen mode
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 }
Enter fullscreen mode Exit fullscreen mode

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.

movementX - MDN

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)