Draggable objects

from Red Blob Games

Many of my interactive pages have a draggable object. I want the reader to move the object around, and I want the diagram to respond in some way. Here I’ll document the code I use to make this work with both mouse and touch input, using browser features that are widely supported since 2020. Here’s a common case I want to support:

Drag me

Drag the circle with mouse or touch

This is the simple model in my head:

state.svg

However it’s not so simple! Mouses have multiple buttons. Touch events can include multiple fingers. Events can go to multiple destinations. Right click can trigger the context menu. I ended up with this basic recipe:

function makeDraggable(state, el, options) {
  function start(event) {
    if (event.button !== 0) return // left button only
    let { x, y } = state.eventToCoordinates(event)
    state.dragging = { dx: state.pos.x - x, dy: state.pos.y - y }
    el.setPointerCapture(event.pointerId)
  }

  function end(event) {
    state.dragging = null
  }

  function move(event) {
    if (!state.dragging) return
    let { x, y } = state.eventToCoordinates(event)
    state.pos = { x: x + state.dragging.dx, y: y + state.dragging.dy }
  }

  el.addEventListener("pointerdown", start)
  el.addEventListener("pointerup", end)
  el.addEventListener("pointercancel", end)
  el.addEventListener("pointermove", move)
  el.addEventListener("touchstart", (e) => e.preventDefault())
}
  • Text inside draggable
  • Multiple fingers on same draggable
  • Nested draggable elements

Like other recipes, it’s something that works for many cases, but is meant to be modified. On the rest of the page I’ll show how I got here and then variants of this recipe, including how to handle text selection. I also use this same recipe to handle situations where I’m not dragging an object around, like dragging this number left/right: 123, or painting on a canvas.

I’ve tested this code on Gecko/Firefox (Mac, Windows, Linux, Android), Blink/Chrome (Mac, Windows, Linux, Android), and WebKit/Safari (Mac, iPhone, iPad). I have not tested on hoverable stylus, hybrid touch+mouse devices, or voice input.

Also see my other pages:

  1. List of edge cases and list of dragging tests
  2. Test page to log the events, and unorganized notes
  3. How I handled dragging events 2015–2018

Note that this is not the HTML Drag and Drop API, which involves dragging an element onto another element. For my diagrams, I’m dragging but not dropping, and the scrubbable number example shows how I’m not necessarily even moving something around. So I need to read the mouse/touch events directly.

1 🖱️ Mouse events only#

When I first started implementing interactive diagrams ~20 years ago, touch devices weren’t common. I used mousedown, mouseup, and mousemove event handlers on the draggable element. If the move occurs while dragging, move the circle to the mouse position.

mouse-local.svg

function makeDraggableMouseLocal(state, el) {
  function mousedown(_event) {
    state.dragging = true
  }
  function mouseup(_event) {
    state.dragging = null
  }
  function mousemove(event) {
    if (!state.dragging) return
    state.pos = state.eventToCoordinates(event)
  }

  el.addEventListener("mousedown", mousedown)
  el.addEventListener("mouseup", mouseup)
  el.addEventListener("mousemove", mousemove)
}

Try the demo with a mouse:

Drag me

Drag using mouse events on circle

This might seem like it works but it works poorly.

  • If you move the pointer quickly it is no longer over the circle, it stops receiving events.
  • If you release the button while not on the circle, it will get stuck in the “dragging” state.

To fix these problems use mousedown on the circle to add mousemove and mouseup on the document. Then on mouseup remove the mousemove and mouseup from the document.

mouse-document.svg

function makeDraggableMouseGlobal(state, el) {
  function globalMousemove(event) {
    state.pos = state.eventToCoordinates(event)
    state.dragging = true
  }
  function globalMouseup(_event) {
    document.removeEventListener("mousemove", globalMousemove)
    document.removeEventListener("mouseup", globalMouseup)
    state.dragging = null
  }

  function mousedown(event) {
    document.addEventListener("mousemove", globalMousemove)
    document.addEventListener("mouseup", globalMouseup)
  }

  el.addEventListener("mousedown", mousedown)
}

Try it out. It works better.

Drag me

Drag using mouse events on document

This code doesn’t handle touch events.

2 👆 Touch events#

Mouse events use mousedown, mouseup, mousemove. Touch events instead use touchstart, touchend, touchmove. They behave a little differently. Touch events automatically capture on touchstart and direct all touchmove events to the original element. This means we don’t have to temporarily put an event handler on document. We can go back to the simpler logic in the first mouse example. If for any reason the browser needs to cancel the touch sequence, it sends touchcancel.

touch.svg

function makeDraggableTouch(state, el) {
  function start(event) {
    event.preventDefault() // prevent scrolling
    state.dragging = true
  }
  function end(_event) {
    state.dragging = false
  }
  function move(event) {
    if (!state.dragging) return
    state.pos = state.eventToCoordinates(event.changedTouches[0])
  }

  el.addEventListener("touchstart", start)
  el.addEventListener("touchend", end)
  el.addEventListener("touchcancel", end)
  el.addEventListener("touchmove", move)
}

Try the demo with a touch device:

Drag me

Drag using touch events

This code doesn’t handle mouse events.

3 🖱️👆 Pointer events#

Handling both mouse and touch events requires lots of event handlers, and that’s what I used before 2021. Details→

From 2011 to 2014 I used d3-drag[1] in projects where I used d3. For my non-d3 projects, I ended up developing my own mouse+touch code, which I wrote about in 2018.

By 2012 MS IE had added support for pointer events[2] which unify and simplify mouse+touch handling. Chrome added support in 2017; Firefox in 2018; Safari in 2020[3].

Over the years browsers have changed the rules, including in 2017 when Chrome changed some events to default to passive mode[4] which causes the page to scroll while trying to drag the object. This broke some pages[5]. Safari made this change in 2018[6]. Firefox also made this change in 2018[7].

mouse-and-touch.svg

Pointer events attempt to unify mouse and touch events. The pointer capture[8] feature lets us use the simpler logic that doesn’t require us to add/remove global event handlers to the document like we had to with mouse events.

pointer.svg

This recipe is the starting point:

function makeDraggable(state, el, options) {
  function start(event) {
    let { x, y } = state.eventToCoordinates(event)
    state.dragging = true
  }

  function end(event) {
    state.dragging = false
  }

  function move(event) {
    if (!state.dragging) return
    let { x, y } = state.eventToCoordinates(event)
    state.pos = { x, y }
  }

  el.addEventListener("pointerdown", start)
  el.addEventListener("pointerup", end)
  el.addEventListener("pointercancel", end)
  el.addEventListener("pointermove", move)
}

Much simpler! However, I almost always want to handle some extras, so I start with this instead:

function makeDraggable(state, el, options) {
  function start(event) {
    if (event.button !== 0) return // left button only
    let { x, y } = state.eventToCoordinates(event)
    state.dragging = { dx: state.pos.x - x, dy: state.pos.y - y }
    el.setPointerCapture(event.pointerId)
  }

  function end(event) {
    state.dragging = null
  }

  function move(event) {
    if (!state.dragging) return
    let { x, y } = state.eventToCoordinates(event)
    state.pos = { x: x + state.dragging.dx, y: y + state.dragging.dy }
  }

  el.addEventListener("pointerdown", start)
  el.addEventListener("pointerup", end)
  el.addEventListener("pointercancel", end)
  el.addEventListener("pointermove", move)
  el.addEventListener("touchstart", (e) => e.preventDefault())
}

Try the demo with either a mouse or touch device:

Drag me

Drag using pointer events

Let’s look at each of the extras.

3.1. 🖱️ Fix: capture the mouse#

The pointer capture feature lets us track the pointer even when it’s not on the circle, the diagram, or even the browser window. With mouse events we had to put event handlers on document, but pointer capture is simpler.

Try thisWatch forCircle 1Circle 2
drag quickly back and forthdrag stopsyes ⛌no ✓
drag outside diagram, come back indrag stopsyes ⛌no ✓
drag outside diagram, let godrag stopsno ⛌yes ✓
drag outside diagram, let go, come back indrag stopsno ⛌yes ✓
drag, alt+tab to another windowdrag stopsno ⛌yes ✓

Drag 1 Drag 2

Dragging without and with pointer capture

Try this demo with a mouse.

  • Circle 1 doesn’t use pointer capture on mouse. Pointer capture is the default on touch devices.
  • Circle 2 uses pointer capture on both mouse and touch devices.
function makeDraggable(state, el, options) {
  function start(event) {
    if (event.button !== 0) return // left button only
    let { x, y } = state.eventToCoordinates(event)
    state.dragging = { dx: state.pos.x - x, dy: state.pos.y - y }
    el.setPointerCapture(event.pointerId)
  }

  function end(event) {
    state.dragging = null
  }

  function move(event) {
    if (!state.dragging) return
    let { x, y } = state.eventToCoordinates(event)
    state.pos = { x: x + state.dragging.dx, y: y + state.dragging.dy }
  }

  el.addEventListener("pointerdown", start)
  el.addEventListener("pointerup", end)
  el.addEventListener("pointercancel", end)
  el.addEventListener("pointermove", move)
  el.addEventListener("touchstart", (e) => e.preventDefault())
}

3.2. 👆 Fix: scrolling with touch#

On touch devices, single-finger drag will scroll the page. But single-finger drag also drags the circle. By default, it will do both! The simplest fix is to add CSS touch-action: none on the diagram. But this prevents scrolling anywhere in the diagram:

Drag 1

Stop touch from scrolling anywhere on the diagram

Try dragging the circle on a touch device. It shouldn’t scroll. But then try scrolling by dragging the diagram. It doesn’t scroll either, but I want it to. I want to stop scrolling only if dragging the circle, not when dragging the diagram.

Try thisWatch forCircle 1Circle 2Circle 3Circle 4
drag circlepage scrollsno ✓yes ⛌yes ⛌no ✓
drag diagrampage scrollsno ⛌yes ✓yes ✓yes ✓

Drag 2 Drag 3 Drag 4

Dragging affects scrolling

Try these on a touch device.

  • Circle 1 (touch-action: none on the diagram) stops scrolling on the circle and also on the diagram.
  • Circle 2 (default) doesn’t stop scrolling on either.
  • Circle 3 (touch-action: none on the circle only) behaves badly. It looks like the CSS has to be on the diagram to have an effect; applying it only to the circle is not enough.
  • Circle 4 (.preventDefault() on touchstart) behaves the way I want, and this is the code for it:
function makeDraggable(state, el, options) {
  function start(event) {
    if (event.button !== 0) return // left button only
    let { x, y } = state.eventToCoordinates(event)
    state.dragging = { dx: state.pos.x - x, dy: state.pos.y - y }
    el.setPointerCapture(event.pointerId)
  }

  function end(event) {
    state.dragging = null
  }

  function move(event) {
    if (!state.dragging) return
    let { x, y } = state.eventToCoordinates(event)
    state.pos = { x: x + state.dragging.dx, y: y + state.dragging.dy }
  }

  el.addEventListener("pointerdown", start)
  el.addEventListener("pointerup", end)
  el.addEventListener("pointercancel", end)
  el.addEventListener("pointermove", move)
  el.addEventListener("touchstart", (e) => e.preventDefault())
}

I use the .preventDefault() solution. Note that it needs to be on touchstart, not on pointerstart. It works in most situations but I’ve run into situations where it did not stop scrolling on certain systems.

3.3. 🖱️ Feature: handle drag offset#

This isn’t necessary but it makes dragging feel nicer. If you pick up the edge of an object then you want to keep holding it at that point, not from the center of the object. The solution is to remember where the center is relative to where the drag started. Then when moving the object, add that offset back in.

Try thisWatch forCircle 1Circle 2
drag from edge of circlecircle jumpsyes ⛌no ✓

Drag 1 Drag 2

Dragging feels better if relative to the initial pickup point

Try with the mouse: drag the circle from the edge. Watch Circle 1 jump whereas Circle 2 does not. The same effect happens on touch devices but your finger might hide the jump. The fix is to change the dragging state from true / false to the relative position where the object was picked up, and then use that offset when later setting the position:

function makeDraggable(state, el, options) {
  function start(event) {
    if (event.button !== 0) return // left button only
    let { x, y } = state.eventToCoordinates(event)
    state.dragging = { dx: state.pos.x - x, dy: state.pos.y - y }
    el.setPointerCapture(event.pointerId)
  }

  function end(event) {
    state.dragging = null
  }

  function move(event) {
    if (!state.dragging) return
    let { x, y } = state.eventToCoordinates(event)
    state.pos = { x: x + state.dragging.dx, y: y + state.dragging.dy }
  }

  el.addEventListener("pointerdown", start)
  el.addEventListener("pointerup", end)
  el.addEventListener("pointercancel", end)
  el.addEventListener("pointermove", move)
  el.addEventListener("touchstart", (e) => e.preventDefault())
}

Tracking the offset makes dragging feel better. I’ve also written about this on my page about little details.

3.4. 🖱️ Fix: context menu#

Context menus are different across platforms, and that makes handling it tricky. I want to allow context menus without them interfering with dragging the circle.

SystemActivation
Windowsright click (down+up), Shift + F10 key
Linuxright button down, Shift + F10 key
Macright button down, Ctrl + left click
iOSlong press on text only
Androidlong press on anything

One problem is that I will see a pointerdown event and only sometimes a pointerup event. That means I might think the button is still down when it’s not. It’s frustrating! I realized that I should only set the dragging state on left mouse button, and ignore the right mouse button. Then I don’t have to worry about most of the differences.

I made some notes during testing, but most of them don’t matter for my use case.

Across platforms, it looks like Firefox lets the page see events outside the menu overlay, whereas Chrome doesn’t let the page see any events while the menu is up.

Windows, right click, no capture:

Firefox, Chrome, Edge

pointerdown, pointerup, auxclick, contextmenu

Windows, right click, capture:

Firefox

pointerdown, gotpointercapture, pointerup, lostpointercapture, auxclick, contextmenu

Chrome, Edge

pointerdown, gotpointercapture, pointerup, auxclick, lostpointercapture, contextmenu

Linux right click, no capture:

Firefox

pointerdown, contextmenu, pointermove while menu is up

Chrome

pointerdown, contextmenu, no pointermove while menu is up

Linux hold right down, no capture:

Firefox

pointerdown, contextmenu, pointermove while menu is up

Chrome

pointerdown, contextmenu, no pointermove while menu is up

Linux right click, capture:

Firefox

pointerdown, contextmenu, gotpointercapture, pointermove while menu is up tells us button released

Chrome

pointerdown, contextmenu, gotpointercapture; not until another click do we get pointerup, lostpointercapture

Linux hold right down, capture:

Firefox

pointerdown, contextmenu, gotpointercapture, pointermove while menu is up tells us button released; when releasing button, menu stays up but we get pointerup, lostpointercapture

Chrome

pointerdown, contextmenu, gotpointercapture, no pointermove while menu is up; when releasing button, menu stays up but we don’t get pointerup; not until another click do we get pointerup, click, lostpointercapture

Mac, ctrl + left click:

Firefox

pointermove with buttons≠0, contextmenu (no pointerdown or pointerup)

Chrome

pointerdown with button=left, contextmenu (no pointerup)

Safari

pointerdown with button=left, contextmenu (no pointerup); but subsequent clicks only fire contextmenu

Mac, right button down:

Firefox

pointerdown with button=right, contextmenu (no pointerup)

Chrome

pointerdown with button=right, contextmenu (no pointerup)

Safari

pointerdown with button=right, contextmenu (no pointerup); but subsequent right clicks only fire contextmenu

If we capture events on pointerdown, Firefox and Safari will keep the capture even after the button is released. Chrome will keep capture until you move the mouse, and then it will release capture. [This seems like a Firefox/Safari bug to me, as pointer capture is supposed to be automatically released on mouse up]

It’s frustrating that on Mac, there’s no pointerup or pointercapture when releasing the mouse button. On Linux, the pointerup only shows up if you click to exit the context menu. It doesn’t show up if you press Esc to exit. The workaround is to watch pointermove events to see when no buttons are set. Windows doesn’t seem to have these issues, as both pointerdown and pointerup are delivered before the context menu.

Android, long press:

Firefox

pointerdown, get capture, contextmenu, pointerup, lose capture

Chrome

pointerdown, get capture, contextmenu, pointerup or pointercancel (if the finger moves at all, this starts a scroll event which cancels the captured pointer), lose capture

What are my options?

  • The spec says about pointerdown[9] that preventDefault()not stop click or contextmenu events. I can preventDefault() on contextmenu to prevent the menu. But I still want to get pointerup and/or pointercancel! I think I have to treat contextmenu as the up event which means I’ll get multiple up events on Windows.
  • The spec says about the button property[10] that button = 0 indicates the primary button. This is how I will exclude the middle and right buttons. But I still get a pointerdown.left on Mac/Chrome and Mac/Safari (but not on Mac/Firefox) so I also have to check for the Ctrl key.
  • Button changes not communicated through pointerdown or pointerup can still be sent on pointermove. It’s mentioned as a workaround on W3C’s pointerevents issues page[11].
Try thisWatch forCircle 1Circle 2Circle 3Circle 4
right clickcircle turns blueyes ⛌yesno ✓no ✓
right clickcontext menuyes ⛌nono ✓no ✓
middle clickcircle turns blueyes ⛌yes ⛌no ✓no ✓
right dragcircle is blueyes ⛌yesno ✓no ✓
middle dragcircle is blueyes ⛌yesno ✓no ✓
ctrl+click (mac)circle turns blue¹yes ⛌no ✓yes ⛌no ✓
ctrl+click (mac)context menuyes ⛌no ✓yes ⛌no ✓

¹ it will turn blue in Chrome and Safari but not in Firefox, which treats Ctrl + click differently

Drag 1 Drag 2 Drag 3 (Mac) Drag 4

Right mouse button down interferes with drag

Try with the mouse: right click or drag on the circles. Try dismissing the menu with a click elsewhere, or by pressing Esc. Behavior varies across browsers and operating systems.

  • Circle 1 sometimes get stuck in a dragging state.
  • Circle 2 uses .preventDefault() on contextmenu. This allows the right button to be used for dragging. However, it interferes with the default operation of middle click or drag, which is used for scrolling on some systems.
  • Circle 3 drags only with the left button, but on Mac Ctrl + click on Chrome/Safari will trigger drag.
  • Circle 4 drags only with the left button, if Ctrl isn’t pressed.
function makeDraggable(state, el, options) {
  function start(event) {
    if (event.button !== 0) return // left button only
    if (event.ctrlKey) return // ignore ctrl+click
    let { x, y } = state.eventToCoordinates(event)
    state.dragging = true
    el.setPointerCapture(event.pointerId)
  }

  function end(event) {
    state.dragging = false
  }

  function move(event) {
    if (!state.dragging) return
    let { x, y } = state.eventToCoordinates(event)
    state.pos = { x, y }
  }

  el.addEventListener("pointerdown", start)
  el.addEventListener("pointerup", end)
  el.addEventListener("pointercancel", end)
  el.addEventListener("pointermove", move)
  el.addEventListener("touchstart", (e) => e.preventDefault())
}

4 🖱️ Variant: draggable text/images#

These changes are needed if you have text or images inside your draggable element:

function makeDraggable(state, el, options) {
  function start(event) {
    if (event.button !== 0) return // left button only
    let { x, y } = state.eventToCoordinates(event)
    state.dragging = { dx: state.pos.x - x, dy: state.pos.y - y }
    el.setPointerCapture(event.pointerId)
    el.style.userSelect = "none" // if there's text
    el.style.webkitUserSelect = "none" // safari
  }

  function end(event) {
    state.dragging = null
    el.style.userSelect = "" // if there's text
    el.style.webkitUserSelect = "" // safari
  }

  function move(event) {
    if (!state.dragging) return
    let { x, y } = state.eventToCoordinates(event)
    state.pos = { x: x + state.dragging.dx, y: y + state.dragging.dy }
  }

  el.addEventListener("pointerdown", start)
  el.addEventListener("pointerup", end)
  el.addEventListener("pointercancel", end)
  el.addEventListener("pointermove", move)
  el.addEventListener("touchstart", (e) => e.preventDefault())
  el.addEventListener("dragstart", (e) => e.preventDefault())
}

4.1. 🖱️ Fix: text selection#

When dragging the circle, the text inside gets selected sometimes. To fix this, use CSS user-select: none on the circle. There are two choices: either we can apply it all the time, or apply it only while dragging. If I apply it all the time, then the text won’t ever be selectable.

Try thisWatch forCircle 1Circle 2Circle 3
drag circletext is selectedyes ⛌no ✓no ✓
select all texttext is selectedyesnoyes

Drag text 1 Drag text 2 Drag text 3

Dragging affects text selection

Try dragging quickly with the mouse. Try selecting all text on the page to see whether the text inside the circle is selectable when not dragging.

  • Circle 1 never uses user-select: none
  • Circle 2 always uses user-select: none
  • Circle 3 uses user-select: none only when dragging

I think either Circle 2 or Circle 3’s behavior is a reasonable choice. Note that as of early 2023, Safari still  doesn’t support the unprefixed version[12] (tracking bug[13]), so we have to also set the prefixed version.

4.2. 🖱️ Fix: text and image drag#

Windows, Linux, and Mac support inter-application drag and drop of text and images, and an alternative to copy/paste. This interferes with the object dragging on my pages. The fix is to preventDefault() on dragstart.

Try thisWatch forCircle 1Circle 2
select text, drag circlepage text dragsyes ⛌no ✓

Select text → [from here

1 2

to here] ←

Selected text interferes with dragging

Try this demo with a mouse. Select the text around the diagram, then drag Circle 1. On most desktop systems I’ve tested, text or image dragging takes priority over the circle dragging by default. Circle 2 prioritizes the circle dragging. Behavior varies a little bit across browsers and operating systems. The fix is one extra line:

5 More cases#

5.1. 👆 Feature: simultaneous dragging#

I think this is an edge case, but I was curious what it would take to support. Can we drag multiple objects at once, using different fingers or different mice?

For touch, the code I presented should already work! Go back to one of the previous demos and try it. However the code doesn’t handle using two fingers to drag the same object. The fix is when handling pointerdown, save event.pointerId to state.dragging. Then when handling pointermove, ignore the even if it’s not the same pointerId. I don’t have that implemented here, but try it out on my canvas dragging test.

function makeDraggable(state, el, options) {
  function start(event) {
    if (event.button !== 0) return // left button only
    let { x, y } = state.eventToCoordinates(event)
    state.dragging = { dx: state.pos.x - x, dy: state.pos.y - y }
    state.pointerId = event.pointerId // keep track of finger
    el.setPointerCapture(event.pointerId)
  }

  function end(event) {
    state.dragging = null
  }

  function move(event) {
    if (!state.dragging) return
    if (state.pointerId !== event.pointerId) return // check finger id
    let { x, y } = state.eventToCoordinates(event)
    state.pos = { x: x + state.dragging.dx, y: y + state.dragging.dy }
  }

  el.addEventListener("pointerdown", start)
  el.addEventListener("pointerup", end)
  el.addEventListener("pointercancel", end)
  el.addEventListener("pointermove", move)
  el.addEventListener("touchstart", (e) => e.preventDefault())
}

What about mice? The Pointer Events spec[14] says

Current operating systems and user agents don’t usually have a concept of multiple mouse inputs. When more than one mouse device is present (for instance, on a laptop with both a trackpad and an external mouse), all mouse devices are generally treated as a single device - movements on any of the devices are translated to movement of a single mouse pointer, and there is no distinction between button presses on different mouse devices. For this reason, there will usually only be a single mouse pointer, and that pointer will be primary.

I think there isn’t any way to drag different objects with different mice.

5.2. 🖱️ Edge case: chorded button presses#

So here’s a tricky one. If you are using multiple buttons at the same time, what happens? Mouse Events send mousedown for each button press and mouseup for each button release. But Pointer Events work differently. The Pointer Events spec[15] says that the first button that was pressed leads to a pointerdown event, and the last one that was released leads to a pointerup event. But that means we might get a up event on a different button than the down event!

multiple-buttons.svg

Try thisWatch forCircle 1Circle 2
left down, right down, left updraggingyes ⛌no ✓

Drag 1 Drag 2

Multiple button presses is tricky

Try with the mouse: press the left button, press the right button (this may bring up a context menu but ignore it), then release the left button. Is the circle still dragging?

The fix is to check the button state in pointermove:

function makeDraggable(state, el, options) {
  function start(event) {
    if (event.button !== 0) return // left button only
    let { x, y } = state.eventToCoordinates(event)
    state.dragging = { dx: state.pos.x - x, dy: state.pos.y - y }
    el.setPointerCapture(event.pointerId)
  }

  function end(event) {
    state.dragging = null
  }

  function move(event) {
    if (!state.dragging) return
    if (!(event.buttons & 1)) return end(event) // edge case: chords
    let { x, y } = state.eventToCoordinates(event)
    state.pos = { x: x + state.dragging.dx, y: y + state.dragging.dy }
  }

  el.addEventListener("pointerdown", start)
  el.addEventListener("pointerup", end)
  el.addEventListener("pointercancel", end)
  el.addEventListener("pointermove", move)
  el.addEventListener("touchstart", (e) => e.preventDefault())
}

Separately, the pointer capture continues until you release all the buttons, unless you explicitly release capture. I’m not handling this or many other edge cases.

5.3. 🖱️👆 Variant: nested dragging#

If the draggable element contains another draggable element inside of it, both elements will handle the dragging. The fix is to add .stopPropagation() to prevent the inner draggable from passing events up to outer draggable. I don’t have a demo here, but I made one elsewhere[16], where the red draggable is a child of the yellow draggable.

function makeDraggable(state, el, options) {
  function start(event) {
    if (event.button !== 0) return // left button only
    event.stopPropagation() // for nested draggables
    let { x, y } = state.eventToCoordinates(event)
    state.dragging = { dx: state.pos.x - x, dy: state.pos.y - y }
    el.setPointerCapture(event.pointerId)
  }

  function end(event) {
    state.dragging = null
  }

  function move(event) {
    if (!state.dragging) return
    event.stopPropagation() // for nested draggables
    let { x, y } = state.eventToCoordinates(event)
    state.pos = { x: x + state.dragging.dx, y: y + state.dragging.dy }
  }

  el.addEventListener("pointerdown", start)
  el.addEventListener("pointerup", end)
  el.addEventListener("pointercancel", end)
  el.addEventListener("pointermove", move)
  el.addEventListener("touchstart", (e) => e.preventDefault())
}

5.4. 🖱️👆 Variant: dragging on canvas#

I normally work with SVG, but if working with a <canvas> (either 2D Canvas or WebGL), I can’t set the event handlers or mouse pointer shape on the draggable element only. So I set the event handler on the <canvas> and then:

  1. pointerdown, touchstart, dragstart: early return if not over a draggable object
  2. pointermove: set the cursor based on whether it’s over a draggable object

5.5. Variant: hover with mouse#

Sometimes I want to act on hover with the mouse (no buttons pressed) but that doesn’t work with touch devices, so I use drag with touch. I modify the recipe by removing the if (!state.dragging) line from pointermove.

{ DEMO? }

Do I need to release the capture? Yes releasing capture turns out to be important on many of my pages, like responsive-design

5.6. Variant: toggle paint

{ idea is that in addition to dragging I also want to capture the state of the first grid space I’m on, and then use that for all the move events; maybe this should go on a different page }

5.7. TODO: lostpointercapture

I know that lostpointercapture can be used to detect that we lost pointer capture, but I haven’t yet figured out all the situations in which it fires, and what I should do about them.

6 Vue component#

Here’s an attempt at a <Draggable> component in Vue v3, Options API:

<template>
  <g
    :transform="`translate(${modelValue.x},${modelValue.y})`"
    @pointerdown.left="start"
    @pointerup="end"
    @pointercancel="end"
    @pointermove="dragging ? move($event) : null"
    @touchstart.prevent=""
    @dragstart.prevent=""
    :class="{dragging}"
  >
    <slot />
  </g>
</template>

<script>
  import { convertPixelToSvgCoord } from "./svgcoords.js"

  export default {
    props: { modelValue: Object }, // should be {x, y}
    data() {
      return { dragging: false }
    },
    methods: {
      start(event) {
        if (event.ctrlKey) return
        let { x, y } = convertPixelToSvgCoord(event)
        this.dragging = { dx: this.modelValue.x - x, dy: this.modelValue.y - y }
        event.currentTarget.setPointerCapture(event.pointerId)
      },
      end(_event) {
        this.dragging = null
      },
      move(event) {
        let { x, y } = convertPixelToSvgCoord(event)
        this.$emit("update:modelValue", {
          x: x + this.dragging.dx,
          y: y + this.dragging.dy,
        })
      },
    },
  }
</script>

<style scoped>
  g {
    cursor: grab;
  }
  g.dragging {
    user-select: none;
    cursor: grabbing;
  }
</style>

and here’s the same thing in the Composition API:

<template>
  <g
    :transform="`translate(${modelValue.x},${modelValue.y})`"
    @pointerdown.left="start"
    @pointerup="end"
    @pointercancel="end"
    @pointermove="dragging ? move($event) : null"
    @touchstart.prevent=""
    @dragstart.prevent=""
    :class="{dragging}"
  >
    <slot />
  </g>
</template>

<script setup>
  import { ref } from "vue"
  import { convertPixelToSvgCoord } from "./svgcoords.js"

  const props = defineProps({
    modelValue: Object, // should be {x, y}
  })

  const emit = defineEmits(["update:modelValue"])

  const dragging = ref(false)

  function start(event) {
    if (event.ctrlKey) return
    let { x, y } = convertPixelToSvgCoord(event)
    dragging.value = { dx: props.modelValue.x - x, dy: props.modelValue.y - y }
    event.currentTarget.setPointerCapture(event.pointerId)
  }

  function end(event) {
    dragging.value = null
  }

  function move(event) {
    let { x, y } = convertPixelToSvgCoord(event)
    emit("update:modelValue", {
      x: x + dragging.value.dx,
      y: y + dragging.value.dy,
    })
  }
</script>

<style scoped>
  g {
    cursor: grab;
  }
  g.dragging {
    user-select: none;
    cursor: grabbing;
  }
</style>

This component only handles SVG elements, as that’s my primary need. You’ll have to modify it if you’re dragging HTML elements. You can use computed setters on the model value if you want to apply constraints like bounds checking or grid snapping. If you’re using Vue v2 (whether Options API or Composition API), you’ll need to change the v-model prop from modelValue to value and the event from update:modelValue to input.

To use this component, create position variables (data in Options API or ref in Composition API) of type {x, y}, and then draw the desired shape as the child slot of the draggable component. In this example, I have redCircle and blueSquare positions:

<svg viewBox="-100 -100 200 200" style="background: #eee">
  <Draggable v-model="redCircle">
    <circle r="10" fill="hsl(0 50% 50%)" />
  </Draggable>
  <Draggable v-model="blueSquare">
    <rect x="-10" y="-10" width="20" height="20" fill="hsl(220 50% 50%)" />
  </Draggable>
</svg>

I put a full example of this on the Vue Playground[17], with both Options API and Composition API components.