EventsGENERALPLATFORM-SPECIFICDEVICE-SPECIFIC

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.

The question

How do I write one interaction that works with a mouse, a finger and a stylus without three code paths?

The user intent

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.

The obvious build

Handle mousedown, mousemove and mouseup. Touch devices fire mouse events too, so it works everywhere.

Why it breaks

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.

How it breaks in a real browser
  • 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.
  • mousemove stops 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 mousedown never gets cleaned up.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • PointerEvent extends MouseEvent, so clientX, button and the modifier keys are all still there. It adds pointerId, pointerType (mouse, pen or touch), isPrimary, pressure, width/height and tilt.
  • The sequence for a tap or click is pointerdownpointermove* → pointerup → (compatibility mousedown/mouseup) → click. click is a pointer-agnostic activation event and fires for keyboard activation too (Keyboard Events).
  • pointercancel fires 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 that pointerId.
  • setPointerCapture(pointerId) retargets every subsequent event for that pointer to one element until pointerup or an explicit release. This is what makes a drag survive the pointer leaving the element, and it replaces the old "listen on document while dragging" pattern.
  • The CSS touch-action property declares which gestures the browser may claim in a region. touch-action: none is 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-events is a different thing with the same name: it controls hit-testing, not events. pointer-events: none removes an element from hit-testing so input falls through to whatever is painted beneath, and also disables :hover on 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 pointermove handler 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-action that decision may wait on the main thread (Scroll and Input Latency).
  • The avoidable work is nearly all in the handler: reading layout inside pointermove forces 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.

pointerdown to click, and the branch that skips the end
  1. 1
    pointerdown

    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.

  2. 2
    setPointerCapture(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.

  3. 3
    pointermove

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

  4. 4
    pointerup — 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.

  5. 5
    Compatibility mouse events

    For touch, the engine may synthesise mousedown/mouseup afterwards so legacy code keeps working.

    fails by Handling both models at once, so every tap runs the handler twice.

  6. 6
    click

    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.

accessibility specSlider / draggable value controlWhat a pointer-driven control owes the keyboard

semantics Prefer <input type="range">. If rebuilt: role="slider" with aria-valuenow, aria-valuemin, aria-valuemax, an accessible name, and tabindex="0".

Arrow Left / DownDecrease by one step — the same step a small drag would produce.
Arrow Right / UpIncrease by one step.
Home / EndJump to the minimum or maximum.
Page Up / Page DownMove by a larger increment, when the range is wide enough to need one.
TabMoves focus away. The control must never trap it.
Focus
  • 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 pointerdown if the control should be focused after a drag.
Announces
  • The current value changes as the control moves, via the native value or aria-valuenow.
  • aria-valuetext when 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).

Pointer capture, coalescing, and both endings
1let active: number | null = null
2let latestX = 0
3let frame = 0
4
5handle.addEventListener('pointerdown', (e: PointerEvent) => {
6 if (active !== null) return // one contact at a time
7 active = e.pointerId
8 handle.setPointerCapture(e.pointerId) // events follow us anywhere
9 latestX = e.clientX
10})
11
12handle.addEventListener('pointermove', (e: PointerEvent) => {
13 if (e.pointerId !== active) return
14 // device rate can exceed frame rate: take the last coalesced sample
15 const samples = e.getCoalescedEvents?.() ?? [e]
16 latestX = samples[samples.length - 1].clientX
17 frame ||= requestAnimationFrame(commit) // work once per frame, not per sample
18})
19
20const end = (e: PointerEvent) => {
21 if (e.pointerId !== active) return
22 active = null
23 cancelAnimationFrame(frame); frame = 0
24}
25handle.addEventListener('pointerup', end)
26handle.addEventListener('pointercancel', end) // the browser took the gesture
27
28// Declarative, and decided before any handler runs:
29// .handle { touch-action: none } full custom gesture
30// .carousel { touch-action: pan-y } we take horizontal, page keeps vertical

pointercancel 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: none on 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 disabled attribute; for one that must stay focusable and explain itself, aria-disabled plus 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-action is 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 isA style property affecting hit-testingA family of event types: pointerdown, pointermove, pointerup, pointercancel
What it controlsWhether this element can be an event target, and whether :hover appliesWhat information an input event carries and how a gesture is tracked
Typical useLetting clicks fall through a decorative overlay to the content beneathWriting one drag implementation for mouse, touch and pen
Affects the keyboard?No. Focus, Tab and Enter/Space are untouchedNo. Keyboard activation arrives as click
Affects assistive technology?No. The element is still in the accessibility tree and still announcedNo, but a pointer-only implementation leaves AT users with nothing
Common mistakeUsing 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, pointerType when a device genuinely differs, and nothing else.
  • Call setPointerCapture() in pointerdown for anything draggable. The drag then keeps working when the pointer leaves the element, moves over an iframe, or outruns the layout.
  • Always handle pointercancel and reset exactly what pointerdown set up. A drag with no cancel path is a drag that gets stuck the first time a user starts scrolling by accident.
  • Set touch-action on the element rather than cancelling touchstart. touch-action: none for a full drag surface, pan-y for 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 requestAnimationFrame callback (The Rendering Opportunity).
  • Do not use hover as the only way to reveal an affordance. Touch has no hover, and the compat mouseover that 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: none to 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-motion for the animation that follows a drag, and coarse-pointer hit targets large enough for a fingertip (Contrast, Colour and Motion).
  • pointer-events: none is not a disabled state and aria-disabled is not a pointer blocker. Use the real disabled attribute, or aria-disabled plus a handled no-op that explains why (The Rules of ARIA).

What can go wrong

Failure modes
  • A drag with no pointercancel handler. 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 document on pointerdown and removed on pointerup — but the pointerup never 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() inside pointermove. Every sample forces layout, and the drag gets slower the more complex the page beneath it is.
  • pointer-events: none used 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: none applied to a large container, which also disables the page's scrolling inside that region and traps the user.
What can arrive out of order
  • pointercancel can arrive at any point during a gesture, and it arrives *instead of* pointerup — code that only cleans up in pointerup leaks the drag state.
  • Compatibility mouse events and click arrive after pointerup, so state torn down in pointerup is already gone when the click handler runs.
  • Multiple pointers interleave freely: a second pointerdown can arrive before the first pointerup, 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.
Security
  • 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, pressure and 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).
Misreads
  • "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 mousemove stream.
  • "pointer-events: none disables an element." It removes it from hit-testing. Keyboard focus, activation and assistive technology are all untouched.
  • "click is a mouse event." click is 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 on click and use pointer events for continuous gestures.
  • "pointerId is 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-action is how you tell it in advance instead of arguing after the fact.

Measuring it, and what changes in the field

How you would see this
  • 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 pointerType and pointerId during 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).
Slow device, slow network, large data, old tab
  • 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-action settles it declaratively (Passive Listeners).
What this costs
  • Pointer events replace three code paths with one, at the cost of a richer model to learn: capture, cancellation, ids, coalescing and touch-action are 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.target during a capture.
  • touch-action: none gives 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, pointercancel and touch-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.

Concurrencyui-concurrency
Computer Architectureinterruptspolling-vs-interrupts
Domains that do not exist yet
  • 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.