Definition
This article covers the problem where, when you build a UI that lets you drag an element directly to move it or adjust its size (custom drag·resize), code that worked fine with a mouse keeps breaking on mobile touch.
The core tool is Pointer Events. This is a browser API that unifies mouse·touch·pen into a single event system: a press comes in as pointerdown, movement as pointermove, release as pointerup, and the browser hijacking and cancelling the gesture as pointercancel. To work reliably on mobile, you must design four things together on top of this: a policy that decides when to start a drag per input type (mouse vs. touch), state restoration when pointercancel arrives, touch-action (a CSS property that decides whether to disable the browser's default scroll·zoom behavior), and an alternative UI for touch devices that have no mouse and thus can't use hover (the state of hovering the pointer over an element).
Why it's needed
Start from the problem. On touch screens the browser prioritizes scroll·zoom·the long-press menu (the menu that appears on a long press) by default. So unless the app declares "I'll take the gesture over this handle area," the browser mid-drag decides it's a scroll and sends only pointercancel (cancel) without pointerup (release), leaving the drag awkwardly cut off. Also, a handle shown only on mouse hover flickers on touch — where the hover concept is ambiguous — or won't disappear.
Because these symptoms stem from several overlapping causes, you have to tackle them one by one, split by cause (activation policy·cancel handling·touch-action·hover replacement) as below.
How it works
pointerdown: branch on"mouse"vs"touch"viaevent.pointerType. Mouse starts a session immediately on a small move (e.g. 4px); touch starts after a long-press (e.g. 220ms) or a larger move (e.g. 10px, allowing jitter).- Session lifetime: register
pointermove,pointerup, andpointercancelall on anAbortControllersignal. cancel is when it ends withoutpointerupdue to scroll steal·a phone call·multi-touch — restore without committing. touch-action: none: CSS on the drag/resize handle. Without it the browser takes over vertical scroll.contextmenu: block the Android long-press menu withstopPropagationin thewindowcapture phase during a drag session. A right mouse click (button !== 0) has no effect if it doesn't create a session.- Coarse hover: under
@media (hover: none) and (pointer: coarse), show the hover-reveal UI at all times or provide a separate tap path. On touch, sticky-hover causes "tap → appear → tap elsewhere → disappear" to repeat. - Click suppression: after a drag ends, a
onceclick listener can swallow the next tap because touch has no trailingclick— release it with a short timeout.
Practical application
Outside-click dismissal handles not only mousedown but also pointerdown.
Trade-offs
| Choice | Benefit | Cost |
|---|---|---|
| Touch long-press activation | Fewer clashes with scroll | Drag start is slightly delayed |
touch-action: none | Gesture ownership | No scroll in that area — minimize the hit zone |
| coarse always-visible | Removes flicker | Screen density·visual noise |
| HTML5 DnD API | Browser-native | Heavy constraints on touch·custom UX |
When not to use
touch-action: noneon the whole page — scroll paralysis.- Handling only
pointerupwithoutpointercancel— stuck state. - Only a hover-only handle with no coarse fallback.
Common mistakes
- Applying the same 4px move threshold as desktop to touch.
- Verifying event suppression via
defaultPrevented— if another handler alwayspreventDefaults, there's no signal; use a bubble-phase probe. - Test failures in jsdom because
scrollBy/elementFromPointare unimplemented — stub the environment.
Related concepts
- html5-drag-and-drop — native DnD vs Pointer Events
- selectionchange-floating-ui — selection·floating UI and pointers