Pointer Events
One event model for mouse, touch and pen — plus pointer capture, gesture cancellation, and the unrelated CSS property that shares the name.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
How do I write one interaction that works with a mouse, a finger and a stylus without three code paths?
A person drags a slider, resizes a panel, or draws on a canvas. They do it with whatever input device is in front of them and expect the same behaviour from each.
Handle mousedown, mousemove and mouseup. Touch devices fire mouse events too, so it works everywhere.
Touch emits mouse events only as a compatibility afterthought, after the gesture is over. A drag implemented on mouse events does not track a finger; it jumps once at the end, if it fires at all.
- Touch emits mouse events only as a compatibility afterthought, after the gesture is over. A drag implemented on mouse events does not track a finger; it jumps once at the end, if it fires at all.
- The compatibility events are suppressed entirely when the touch was consumed by a gesture, so the same code sometimes works and sometimes does nothing, depending on how the user moved.
- Multi-touch has no mouse equivalent. Two fingers produce one confused mouse cursor and no way to tell the contacts apart.
- Pressure, tilt, contact size and eraser state exist on a stylus and are simply unavailable through the mouse model — the input a drawing app most needs is the input it cannot see.
mousemovestops when the pointer leaves the element, so a drag that moves faster than the layout updates loses its target mid-gesture and sticks.- The browser can take the gesture away at any moment to scroll or zoom. The mouse model has no event for that, so state initialised on
mousedownnever gets cleaned up.
What is actually happening
In the browser, not in the framework.
PointerEventextendsMouseEvent, soclientX,buttonand the modifier keys are all still there. It addspointerId,pointerType(mouse,penortouch),isPrimary,pressure,width/heightand tilt.- The sequence for a tap or click is
pointerdown→pointermove* →pointerup→ (compatibilitymousedown/mouseup) →click.clickis a pointer-agnostic activation event and fires for keyboard activation too (Keyboard Events). pointercancelfires when the browser takes over the gesture — a scroll or pinch began, the pointer was captured elsewhere, the device was lifted in a way the platform treats as an abort. After it, no further events arrive for thatpointerId.setPointerCapture(pointerId)retargets every subsequent event for that pointer to one element untilpointerupor an explicit release. This is what makes a drag survive the pointer leaving the element, and it replaces the old "listen ondocumentwhile dragging" pattern.- The CSS
touch-actionproperty declares which gestures the browser may claim in a region.touch-action: noneis how you tell the compositor up front that this element handles its own gestures — declaratively, without a handler having to cancel anything (Passive Listeners). - The CSS property
pointer-eventsis a different thing with the same name: it controls hit-testing, not events.pointer-events: noneremoves an element from hit-testing so input falls through to whatever is painted beneath, and also disables:hoveron it (How an Event Is Dispatched). - Movement events are delivered at the input device's rate, which on a high-refresh screen or a stylus is faster than the frame rate.
getCoalescedEvents()gives you the samples the browser merged;getPredictedEvents()gives its guesses ahead of the last sample.
What this makes the browser do
And which of it is avoidable.
- Hit-testing every pointer sample against the composited frame, then dispatching an event object per sample. A
pointermovehandler is the easiest place in the browser to spend an entire frame budget (The Frame Budget). - Producing compatibility mouse events for touch, which is duplicate dispatch you can avoid entirely by handling pointer events and letting the compat pair go unhandled.
- Deciding, on every touch, whether the gesture belongs to the page or to your handler. Without
touch-actionthat decision may wait on the main thread (Scroll and Input Latency). - The avoidable work is nearly all in the handler: reading layout inside
pointermoveforces a synchronous layout per sample, which is the classic way to turn a smooth drag into a stutter (Layout Thrashing).
The life of one gesture
The sequence below is the same for a mouse click, a fingertip tap and a pen stroke. That uniformity is the point of the API: a drag written against it works with all three, and the only place the device shows up is where it genuinely matters — pressure for a brush, hover for a preview, contact size for a hit-target decision.
The step most implementations omit is the one that has no mouse equivalent. pointercancel is not an error case; it is the routine outcome of a user starting to scroll while their finger happens to be on your element.
- 1pointerdown
A contact begins. Capture the pointer here, record the id, and record the starting geometry once.
fails by Ignoring
pointerId, so a second finger drives the same state as the first. - 2setPointerCapture(pointerId)
Retargets all further events for this pointer to this element, whatever it moves over.
fails by Being skipped — then the drag dies when the pointer leaves the element or crosses an iframe.
- 3pointermove
Delivers samples at device rate. Record the position; do the work in the next frame.
fails by Reading layout per sample, forcing a synchronous layout each time (Layout Thrashing).
- 4pointerup — OR — pointercancel
Either the gesture completed, or the browser claimed it for a scroll or zoom. Exactly one of the two arrives.
fails by Cleaning up only in
pointerup, leaving the drag stuck after any cancelled gesture. - 5Compatibility mouse events
For touch, the engine may synthesise
mousedown/mouseupafterwards so legacy code keeps working.fails by Handling both models at once, so every tap runs the handler twice.
- 6click
The activation event — also produced by Enter, Space and assistive technology, with no pointer at all.
fails by Building activation on
pointerup, which keyboard and screen-reader users never send (Keyboard Events).
Activation belongs on click. Pointer events are for the continuous part in the middle.
A drag that survives the real world — and the keyboard path beside it
The code is short because pointer capture removes the entire category of workarounds the mouse model needed: no listeners on document, no tracking whether the pointer left the element, no guessing whether a missing mouseup means the gesture ended. Capture, sample, and handle both endings.
The accessibility spec next to it is not a separate task. A drag is a way to change a value; the keyboard needs a way to change the same value, and the screen reader needs to hear it change. If the control is a slider, <input type="range"> provides all of this and the honest recommendation is to use it.
semantics Prefer <input type="range">. If rebuilt: role="slider" with aria-valuenow, aria-valuemin, aria-valuemax, an accessible name, and tabindex="0".
| Arrow Left / Down | Decrease by one step — the same step a small drag would produce. |
| Arrow Right / Up | Increase by one step. |
| Home / End | Jump to the minimum or maximum. |
| Page Up / Page Down | Move by a larger increment, when the range is wide enough to need one. |
| Tab | Moves focus away. The control must never trap it. |
- — The handle is focusable and shows a visible focus indicator that is not removed along with the default outline.
- — Focus stays on the handle for the whole interaction, including during a pointer drag, so keyboard and pointer never disagree about what is active.
- — Pointer capture does not move focus by itself — set it explicitly on
pointerdownif the control should be focused after a drag.
- — The current value changes as the control moves, via the native value or
aria-valuenow. - —
aria-valuetextwhen the raw number is not meaningful — "Medium", "3 of 5", a formatted currency (Internationalization). - — The accessible name states what is being adjusted, not that it is a slider.
usually broken by Building the whole control on pointerdown/pointermove and never adding key handlers. It is fully usable with a mouse and completely inoperable with a keyboard, and no automated check will call a working mouse interaction a failure (Accessibility Testing).
1let active: number | null = null2let latestX = 03let frame = 04 5handle.addEventListener('pointerdown', (e: PointerEvent) => {6 if (active !== null) return // one contact at a time7 active = e.pointerId8 handle.setPointerCapture(e.pointerId) // events follow us anywhere9 latestX = e.clientX10})11 12handle.addEventListener('pointermove', (e: PointerEvent) => {13 if (e.pointerId !== active) return14 // device rate can exceed frame rate: take the last coalesced sample15 const samples = e.getCoalescedEvents?.() ?? [e]16 latestX = samples[samples.length - 1].clientX17 frame ||= requestAnimationFrame(commit) // work once per frame, not per sample18})19 20const end = (e: PointerEvent) => {21 if (e.pointerId !== active) return22 active = null23 cancelAnimationFrame(frame); frame = 024}25handle.addEventListener('pointerup', end)26handle.addEventListener('pointercancel', end) // the browser took the gesture27 28// Declarative, and decided before any handler runs:29// .handle { touch-action: none } full custom gesture30// .carousel { touch-action: pan-y } we take horizontal, page keeps verticalpointercancel sharing a handler with pointerup is the whole robustness story. Handling only one of them is the most common drag bug there is.
Two unrelated things called "pointer events"
The CSS property pointer-events and the DOM Pointer Events API share a name and nothing else. The property is a hit-testing switch: it decides whether an element can be the target of a pointer at all. The API is a set of event types. Searching for one and reading about the other is a genuinely common way to lose an afternoon.
The dangerous half is pointer-events: none as a stand-in for "disabled". It stops the mouse, and it stops only the mouse. The element keeps its place in the tab order, still receives Enter and Space, and is still announced as an ordinary control — so the interaction is blocked for exactly the users who were least likely to be blocked by anything else.
pointer-events: noneon a full-screen decorative layer is the correct, intended use — that is the case it was designed for.- For a genuinely disabled control use the
disabledattribute; for one that must stay focusable and explain itself,aria-disabledplus a handled no-op (The Rules of ARIA). - SVG has additional values (
visiblePainted,stroke,fill, and others) that let hit-testing follow the painted geometry rather than the bounding box. touch-actionis the third name in this neighbourhood and belongs to gestures, not to hit-testing (Passive Listeners).
| CSS `pointer-events` | DOM Pointer Events API | |
|---|---|---|
| What it is | A style property affecting hit-testing | A family of event types: pointerdown, pointermove, pointerup, pointercancel |
| What it controls | Whether this element can be an event target, and whether :hover applies | What information an input event carries and how a gesture is tracked |
| Typical use | Letting clicks fall through a decorative overlay to the content beneath | Writing one drag implementation for mouse, touch and pen |
| Affects the keyboard? | No. Focus, Tab and Enter/Space are untouched | No. Keyboard activation arrives as click |
| Affects assistive technology? | No. The element is still in the accessibility tree and still announced | No, but a pointer-only implementation leaves AT users with nothing |
| Common mistake | Using none to mean "disabled" | Using pointerup for activation instead of click |
How to build it
Most important first.
- Handle pointer events and stop writing mouse and touch paths. One set of handlers,
pointerTypewhen a device genuinely differs, and nothing else. - Call
setPointerCapture()inpointerdownfor anything draggable. The drag then keeps working when the pointer leaves the element, moves over an iframe, or outruns the layout. - Always handle
pointercanceland reset exactly whatpointerdownset up. A drag with no cancel path is a drag that gets stuck the first time a user starts scrolling by accident. - Set
touch-actionon the element rather than cancellingtouchstart.touch-action: nonefor a full drag surface,pan-yfor a horizontal carousel inside a vertically scrolling page (Passive Listeners). - Do work in a frame, not per sample: record the latest position in the handler and read layout in a
requestAnimationFramecallback (The Rendering Opportunity). - Do not use hover as the only way to reveal an affordance. Touch has no hover, and the compat
mouseoverthat some engines synthesise on tap is not something to design around (Media Queries Beyond Width). - Keep the CSS property and the API strictly separate in your head, and never use
pointer-events: noneto express "disabled" — it removes the mouse path and nothing else.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Pointer interaction is one input path among several. Every drag, resize or swipe needs a keyboard equivalent — arrow keys with a defined step, Home/End for the extremes — and the pointer version is never sufficient on its own (Keyboard Operability).
- A native
<input type="range">gives you the keyboard behaviour, the role, the value announcements and the pointer handling together. Rebuild it only when you genuinely cannot style it, and expect to owe all four (What Native Elements Already Do). - Announce the value, not the gesture. A screen-reader user needs
aria-valuenow(or a native value) to change as the control moves; the pixel position is meaningless to them (Live Regions and Announcement). - Respect motion and pointer preferences:
prefers-reduced-motionfor the animation that follows a drag, and coarse-pointer hit targets large enough for a fingertip (Contrast, Colour and Motion). pointer-events: noneis not a disabled state andaria-disabledis not a pointer blocker. Use the realdisabledattribute, oraria-disabledplus a handled no-op that explains why (The Rules of ARIA).
What can go wrong
- A drag with no
pointercancelhandler. The user starts scrolling mid-drag, the browser claims the gesture, and the element stays stuck to a pointer that no longer exists. - Drag handlers attached to
documentonpointerdownand removed onpointerup— but thepointerupnever arrives because the pointer went over an iframe. Pointer capture makes this impossible. - Ignoring
pointerId, so a second finger on the screen drives the same drag state as the first and the element jumps between two contacts. - Reading
getBoundingClientRect()insidepointermove. Every sample forces layout, and the drag gets slower the more complex the page beneath it is. pointer-events: noneused to make a control look disabled. It is still focusable, still in the tab order, and still activates with Enter or Space — the mouse is the only input that was blocked (Keyboard Operability).- The mitigation failing:
touch-action: noneapplied to a large container, which also disables the page's scrolling inside that region and traps the user.
pointercancelcan arrive at any point during a gesture, and it arrives *instead of*pointerup— code that only cleans up inpointerupleaks the drag state.- Compatibility mouse events and
clickarrive afterpointerup, so state torn down inpointerupis already gone when theclickhandler runs. - Multiple pointers interleave freely: a second
pointerdowncan arrive before the firstpointerup, so single-variable drag state is a data race in slow motion (Reasoning About Races: A Method, Not an Instinct). - A layout change during a drag moves the element out from under the pointer, so the next sample hit-tests to a different node unless the pointer is captured.
- Pointer events are subject to the same origin isolation as everything else: you cannot observe pointer input inside a cross-origin frame, and one inside your page cannot observe yours (The Same-Origin Policy).
- Pointer capture does not cross a frame boundary. A drag started in your page stops receiving events over a cross-origin iframe unless capture is set, which is the boundary working as designed.
- A trusted pointer sequence grants transient user activation for gated APIs — fullscreen, pointer lock, clipboard, autoplay with sound. Synthetic pointer events do not (How an Event Is Dispatched).
pointerType,pressureand tilt are a small fingerprinting surface: they reveal input hardware. It is a real signal for a tracker and a poor basis for a security decision, since it is trivially spoofed by anything running in the page.- Clickjacking is precisely the case where the pointer event is genuine and the user's intent is not. Framing protections, not event handling, are the defence (Clickjacking and Framing).
- "Touch devices fire mouse events, so mouse handlers are enough." They fire them late, as a compatibility measure, and suppress them entirely when the gesture was consumed. A finger drag is not a
mousemovestream. - "
pointer-events: nonedisables an element." It removes it from hit-testing. Keyboard focus, activation and assistive technology are all untouched. - "
clickis a mouse event."clickis an activation event. It fires for Enter and Space on a button and for screen-reader activation, with no pointer involved at all (Keyboard Events). - "Pointer events replace
click." They sit beneath it. Build activation onclickand use pointer events for continuous gestures. - "
pointerIdis stable per device." It identifies one contact for the life of that contact. Lift and re-press and you may get a different id. - "If I handle pointer events I can ignore touch-action." The browser still decides whether the gesture is a scroll;
touch-actionis how you tell it in advance instead of arguing after the fact.
Measuring it, and what changes in the field
- The Performance panel with input recording shows the pointer stream and the handler entries under it. A row of many short handler blocks per frame is the signature of per-sample work.
- Watch the frame rate during a drag rather than the handler duration: the question is whether the compositor kept producing frames, not whether one callback was fast (Debugging Rendering and Jank).
- Log
pointerTypeandpointerIdduring development. Multi-touch bugs are invisible on a desktop with a mouse and obvious the first time two ids appear. - The Rendering pane's paint flashing and layer borders show whether a drag is moving a composited layer or repainting a subtree each frame (Compositing Layers).
- On a touch device there is no hover state at all, so hover-only affordances are invisible; on a stylus, hover exists but only while the pen is near the screen.
- On a high-refresh display, movement events arrive faster than frames, so per-sample work costs proportionally more and coalescing matters more.
- On a slow device, the gap between the gesture and the visual response widens, which reads to the user as the control being "heavy" rather than the app being slow (Interaction Responsiveness).
- On a page that scrolls, every touch gesture is a negotiation between your handler and the scroller until
touch-actionsettles it declaratively (Passive Listeners).
- Pointer events replace three code paths with one, at the cost of a richer model to learn: capture, cancellation, ids, coalescing and
touch-actionare all things the mouse model let you ignore — right up until they broke it. - Pointer capture makes drags robust and makes the event target no longer the element under the pointer, which surprises anyone reading
event.targetduring a capture. touch-action: nonegives you the whole gesture and removes the browser's scrolling in that region. Scoping it too broadly is a genuine accessibility regression.- Building a custom pointer-driven control at all is the largest cost in the lesson: keyboard, value semantics and announcement come free with the native element and must be rebuilt by hand otherwise.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe Pointer Events model — the unified event set, pointer capture,
pointercancelandtouch-action— is specified and implemented across Blink, Gecko and WebKit; the differences are in gesture heuristics, not in the API. - PLATFORM-SPECIFICWhich gestures the platform reserves is an OS and shell decision: iOS Safari claims edge swipes for back-navigation and reserves double-tap zoom, Android claims the pull-to-refresh gesture in some shells, and a desktop browser reserves neither — so identical code loses different gestures per platform.
- DEVICE-SPECIFICHover and pressure exist on a mouse and a stylus but not on a finger, and sampling rate varies by an order of magnitude across input hardware, so per-sample handler cost that is invisible on a trackpad can miss frames on a high-rate stylus.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — pointer-driven interactions are the hardest thing in a frontend to test honestly, because a synthetic click proves nothing about a gesture a browser might have cancelled.