Draggable objects

 from Red Blob Games
4 Jan 2023

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 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:

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.

This recipe is for the input event handler , and it needs to be connected to a  state handler  to handle output. See the examples page for complete code you can use, including when I’m not dragging an object around, like dragging this number left/right: , or painting on a canvas, or constrained dragging.

diagram-state-and-event-handlers.svg

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

Try the demo with a mouse: (won’t work well on touch devices)

Drag using mouse events on circle

This might seem like it works but it works poorly.

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

Try it out. It works better. (only with mouse, not touch)

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

Try the demo with a touch device: (won’t work with a mouse)

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:

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

Try the demo with either a mouse or touch device:

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 ✓
Dragging without and with pointer capture

Try this demo with a mouse.

 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:

Stop touch from scrolling anywhere on the diagram

Try dragging the circle on a touch device. (The issue won’t show with mouse dragging.) 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 ✓
Dragging affects scrolling

Try these on a touch device.

I use the .preventDefault() solution. Note that it needs to be on touchstart, not on pointerstart. It works in most situations. I’ve noticed it doesn’t work on desktop Firefox’s “mobile emulation mode”, but it does work on mobile Firefox (Android).

 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 ✓
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:

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

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.

 4  🖱️ Variant: draggable text/images#

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

 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
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.

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
to here]
Selected text interferes with dragging

Try this demo with a mouse. 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.

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 ✓
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:

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.

 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

I have a demo on the examples page.

 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. The standard recipe at the top of the page expects drag for both mouse and touch. To make it hover with mouse and drag for touch, I modify the recipe by removing the if (!state.dragging) line from pointermove.

For a demo, see my Responsive Design page[17]. With a mouse, you can hover over the rows to change the layout. With touch, you can drag to change the layout. I also do this on the Hexagons Guide. Many diagrams work with mouse hover, but on touch devices they work with touch drag.

Do I need to release the capture? Yes, sometimes. When moving the mouse from item A to item B, if I want to highlight A and then highlight B, hover works as expected. But with touch, dragging from A to B sends the move event to A. By releasing the capture, the move event will go to B.

 5.6. Variant: toggle paint

For the Rounded Cell Painter[18], the pointerdown captures the initial paint color, and then all pointermove events will use that same paint color, until pointerup. To do this, the state.dragging variable should contain not only the initial x, y but also the initial paint color.

I have a demo on the examples 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.

Email me , or tweet @redblobgames, or comment: