https://www.redblobgames.com/making-of/draggable/ Draggable objects from Red Blob Games * Home * Blog * Links * Twitter * About * * [ ] 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: [ ] Text inside draggable[ ] Multiple fingers on same draggable[ ] Nested draggable elements Table of Contents * 1. [?] Mouse events only * 2. Touch events * 3. [?] Pointer events * 4. [?] Variant: draggable text/images * 5. More cases * 6. Vue component 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: [ ], 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 Try the demo with a mouse: 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 Try it out. It works better. 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: 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 this Watch for Circle 1 Circle 2 drag quickly back and forth drag yes [?] no stops drag outside diagram, come back in drag yes [?] no stops drag outside diagram, let go drag no [?] yes stops drag outside diagram, let go, come back drag no [?] yes in stops drag, alt+tab to another window drag no [?] yes stops 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. 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. 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 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: 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 this Watch for Circle 1 Circle 2 drag from edge of circle circle jumps yes [?] 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. 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, 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 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 circle turns yes [?] no yes [?] no (mac) blue1 ctrl+click context menu yes [?] no yes [?] no (mac) 1 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. * 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 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 this Watch for Circle 1 Circle 2 select text, drag circle page text drags yes [?] no Select text - [from here 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. 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 this Watch for Circle 1 Circle 2 left down, right down, left up dragging yes [?] 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 (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 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 component in Vue v3, Options API: and here's the same thing in the Composition API: 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: I put a full example of this on the Vue Playground^[17], with both Options API and Composition API components. Email me redblobgames@gmail.com, or tweet @redblobgames, or comment: Endnotes 1. [1]: https://github.com/d3/d3-drag 2. [2]: https://developer.mozilla.org/en-US/docs/Web/API/ Pointer_events 3. [3]: https://caniuse.com/pointer 4. [4]: https://developer.chrome.com/blog/scrolling-intervention/ 5. [5]: https://github.com/WICG/interventions/issues/18# issuecomment-276531695 6. [6]: https://github.com/WICG/interventions/issues/18# issuecomment-368703063 7. [7]: https://bugzilla.mozilla.org/show_bug.cgi?id=1449268 8. [8]: https://developer.mozilla.org/en-US/docs/Web/API/Element/ setPointerCapture 9. [9]: https://www.w3.org/TR/pointerevents/#the-pointerdown-event 10. [10]: https://w3c.github.io/pointerevents/#the-button-property 11. [11]: https://github.com/w3c/pointerevents/issues/408 12. [12]: https://caniuse.com/?search=user-select 13. [13]: https://bugs.webkit.org/show_bug.cgi?id=208677 14. [14]: https://www.w3.org/TR/pointerevents/#the-primary-pointer 15. [15]: https://www.w3.org/TR/pointerevents/# chorded-button-interactions 16. [16]: https://play.vuejs.org/#eNq1Vk1zmzAQ/ SsqkxnsqUOcyY06nbZpDumhzbQ5JDMcQswalICkkYSNS/nvXUngQOLaTT/ sA9Lbt2L37bJQe++FCJYleKE3U3NJhSYKdCneRowWgktNaiJhQRqykLwgPlL9iEVszpnSpCKnxjqajjtk / QxZ0URnLXo8fcQzoGmmB4bOlMg4RUMdMWLXKWVpSBZxrmBiMMEp0yATvmIjWALTY8clRGdUBZ2LOSKpQlIFyzgvgRwSSw7mOcXL9YQk65CstxpvmjfuwBYspcTrVSxT0AEKdOkiOIuFLiW4III2rItkbJ2bfqyl2BOpze65X8GX7fEbT7ogo1cD9zFqiHGwNuYu39NhvuT18KZBUrUOnQZDh5vnDutNgE1XKwmKfjeu /6Raq9D1yy8rloVt4/y3snXOSnNxKbmI01hTzkZ/ V9QXnPonJd91OBlIurcpVq3TQOa9nZH1O2N25EYJDhHcaChEHmvAHSGzhC5JqPQ6h9PIq7WMmVpwWYTk1q4NcXRQV42oJgf1Gi /j24lLACkHtV0hiqAL0KJu6eC7eP6QSl6yJCT+GvKcr/ wJ8UsF8lBBDnPto4FxBggLrqiRChEJeG+6NCj2jeISMVMKv4k8pwj+3vWaOchhoTEJI0LXRAbfQi / FE14ptrDM3Z7wDNRnal7OM6VjiW0rbUnQoUewGhPy0QzQAkjt9PoxbZrrulXJbBzrSS16WsR3iuelNlpIJ7I /xfUd15pjqeymrYl/ PBUVbrtqdPtBGSQkfVnZSsGhGxx71XW039F3yNyl8JD5Uo03Ks+OUEDX1e1qdtRrdvf3Jp5WOCoXNA3uFWf4orUPdeTNeSFoDvKLMKqryAu7xz3yYtO2nyymZenmqPXJYP6wBb9XlcEi7xJzA2kS2tgwExx / znz+7TNUuN4YC56UObJ3GL+CbQaM0dE+YE0x7B7PRnthPxdwGFyp80oDU11SJtDHIRd5+AlxtiP1x3BPghPrhzPFa34CpcvwOQ == 17. [17]: https://play.vuejs.org/#eNrtV2tv2zYU/ SsXXofImS2lAfbFSfbyimEYhgZIUGyYh1WWrmQ2kqiRlB8z/ N93SD0sO26WtRj2pQZsi+S5l/ d5RG4H35alv6x4MBkE58TrMC8zJplQrMKUwiLGgywpkYr0MiXOOOfC6KtZQURRWNCcKZexSATHZCStpHqglTALWpg86 / BYkrWMZsaSKfUkCFarla84nmdynoY5az+SeZCHD6JIxzIJrAlpOM84oPNgVsyKax0pURroMFX5lVUn8lIqQ1vFyY4SJXM6gzNnbqsgaJe /bxU1ED/ oZl6XRshC2xDUUv8sMpWAaGHl9mL2G0GRITg0FSqC5A2eE2+7ntCXFyPaTGh8ebEbOngNnWcV3 / 1ZhaqPHTfgBnsd1E7DXQwMIz+hYef8tc3IUvDqO7m+mQ3GLy8urDQkLwjPswFps8kYS /MwekiVrIp4Qp8x82zgFEDF3s/lGHnkDOjOgw4GYFT7pLD+0qpORGaxC515F/Dvc/ sdYj5oNe8D9tRe+xD0N1McGWp8gs5N97QSsVlgdGkHCxbpwrSjnkGXl88yCaFdpni6DnphHYwG78s0euQoAWmteGJUWGj0SA4D3rqBxXgvts7NNyGc9Ne70cF4sxu+nQ1qBd+UUhSGVSxXhZ9xYp3SJlTmGFCVWOEihlftFJowcqF004fwXC5t+l0joanoa /TqEnbxEk05pAkVVZZ1QkZW0cJt65fKQSBrd7Lyj+cb36Ms1BoT23aX3b66dCZNE/ 7r4FGojxu67qKnWrpdQ/ssWZlbsebsXt4t06mUKm7hvs1rZGe0/ 04f92cJPtPot5gTUfCtHXnb2t59dib0ev7OFiFIRC9klcWW6LZrdObOYps23mvlXJhO6SsMtPfbWVXG8HSyV3v2+9C63Qp1eam7PwkzzVaxRSRVEdnCQw8j8F6TscZQkVA940dGZT / xZggFplJFzbGUMYLkjIXq08FqNDYCrSX+0poJoW0MKnKh8vs1TGOC2hj09Ghtg7XNrtHXGFcphf / 7UKVsfCT5ti7LaVjCWG5caGr1x7g2BvE98B9lfeT9I2NtFZ+UddV+KPyvQ2Mz651I5ajVSIRQremLI7v8eD1qAYjX5gRg0wCaaoL1h2zv6Jt0JEuOXROltCUEVUs1oVSF8ysrg2m /q6QtVZrVWOPFGxn0tyz46kBkDpQTw1ZW/ RHl9d6Hn+juP6K7g5PLh1EZr5006CasMmhx88b15IR6YW95bDd6H5OhnUITeuiPhkLw0Lo2IUdJtEPBoFRnBhTJZiFju0lb26f46Tkc9UHNaD9mIfS+4BuqcpOnmep46YCoPpKsXPd2bW6p6o /HkTg2uKOrQ+kTZPVxMXrxDOrq0deBmX326jHYEaYlsB6J9X3a2Z//mdn6/ eMuOec0rYNYt5gLHeIKiCgQJ9JlGDF5eHuYBVMZpjy0F5u7Nz/ 0ULDtvEXa246o0dhsRAtZSGUtVqxLcKlY4tjMWvyFuVF9gg3qo6sN07m7ZDWn+KG76DTN3b3Fnkr6CFesmxMF3JVQfdKwN4UbQH0wLiv48qq+mPXOCyUAgPmRYjgIiKt /r81qiZaChnqnTODvl25lc7Tya7uC+dLPQ6PE+r59Z3h2E1h4h424mN7/ 7A19YR3U7A3b3RoqKjFEQge7vwFtIua8 Load comments View the discussion thread. Copyright (c) 2023 Red Blob Games RSS Feed Created Jan 2023 with the help of Prism.js; Last modified: 24 Jul 2023