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:
This is the simple model in my head:
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.
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:
- List of edge cases and list of dragging tests
- Test page to log the events, and unorganized notes
- 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.
Try the demo with a mouse: (won’t work well on touch devices)
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.
Try it out. It works better. (only with mouse, not touch)
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
.
Try the demo with a touch device: (won’t work with a mouse)
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].
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.
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:
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 this | Watch for | Circle 1 | Circle 2 |
---|---|---|---|
drag quickly back and forth | drag stops | yes ⛌ | no ✓ |
drag outside diagram, come back in | drag stops | yes ⛌ | no ✓ |
drag outside diagram, let go | drag stops | no ⛌ | yes ✓ |
drag outside diagram, let go, come back in | drag stops | no ⛌ | yes ✓ |
drag, alt+tab to another window | drag stops | no ⛌ | yes ✓ |
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.
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:
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 this | Watch for | Circle 1 | Circle 2 | Circle 3 | Circle 4 |
---|---|---|---|---|---|
drag circle | page scrolls | no ✓ | yes ⛌ | yes ⛌ | no ✓ |
drag diagram | page scrolls | no ⛌ | yes ✓ | yes ✓ | yes ✓ |
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:
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 this | Watch for | Circle 1 | Circle 2 |
---|---|---|---|
drag from edge of circle | circle jumps | yes ⛌ | no ✓ |
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.
System | Activation |
---|---|
Windows | right click (down+up), Shift + F10 key |
Linux | right button down, Shift + F10 key |
Mac | right button down, Ctrl + left click |
iOS | long press on text only |
Android | long 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
, nopointermove
while menu is up
Linux hold right down, no capture:
- Firefox
pointerdown
,contextmenu
,pointermove
while menu is up- Chrome
pointerdown
,contextmenu
, nopointermove
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 getpointerup
,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 getpointerup
,lostpointercapture
- Chrome
pointerdown
,contextmenu
,gotpointercapture
, nopointermove
while menu is up; when releasing button, menu stays up but we don’t getpointerup
; not until another click do we getpointerup
, click,lostpointercapture
Mac, ctrl + left click:
- Firefox
pointermove
with buttons≠0,contextmenu
(nopointerdown
orpointerup
)- Chrome
pointerdown
with button=left,contextmenu
(nopointerup
)- Safari
pointerdown
with button=left,contextmenu
(nopointerup
); but subsequent clicks only firecontextmenu
Mac, right button down:
- Firefox
pointerdown
with button=right,contextmenu
(nopointerup
)- Chrome
pointerdown
with button=right,contextmenu
(nopointerup
)- Safari
pointerdown
with button=right,contextmenu
(nopointerup
); but subsequent right clicks only firecontextmenu
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
orpointercancel
(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 orcontextmenu
events. I canpreventDefault()
oncontextmenu
to prevent the menu. But I still want to getpointerup
and/orpointercancel
! I think I have to treatcontextmenu
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 apointerdown.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
orpointerup
can still be sent onpointermove
. It’s mentioned as a workaround on W3C’s pointerevents issues page[11].
Try this | Watch for | Circle 1 | Circle 2 | Circle 3 | Circle 4 |
---|---|---|---|---|---|
right click | circle turns blue | yes ⛌ | yes | no ✓ | no ✓ |
right click | context menu | yes ⛌ | no | no ✓ | no ✓ |
middle click | circle turns blue | yes ⛌ | yes ⛌ | no ✓ | no ✓ |
right drag | circle is blue | yes ⛌ | yes | no ✓ | no ✓ |
middle drag | circle is blue | yes ⛌ | yes | no ✓ | no ✓ |
ctrl+click (mac) | circle turns blue¹ | yes ⛌ | no ✓ | yes ⛌ | no ✓ |
ctrl+click (mac) | context menu | yes ⛌ | no ✓ | yes ⛌ | no ✓ |
¹ it will turn blue in Chrome and Safari but not in Firefox, which treats Ctrl + click differently
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.
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 this | Watch for | Circle 1 | Circle 2 | Circle 3 |
---|---|---|---|---|
drag circle | text is selected | yes ⛌ | no ✓ | no ✓ |
select all text | text is selected | yes | no | yes |
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 this | Watch for | Circle 1 | Circle 2 |
---|---|---|---|
select text, drag circle | page text drags | yes ⛌ | no ✓ |
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!
Try this | Watch for | Circle 1 | Circle 2 |
---|---|---|---|
left down, right down, left up | dragging | yes ⛌ | no ✓ |
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:
pointerdown
,touchstart
,dragstart
: early return if not over a draggable objectpointermove
: 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.