Notes from debugging pen input in a browser UI. Five behaviours that broke assumptions I held, or that I have seen repeated as fact.
Everything below was measured twice, on two engines fifty-two versions and four years apart. That turned out to matter: one of the five does not reproduce on the current one, and I would have published it as a general truth if I had not checked.
The two setups#
| A | B | |
|---|---|---|
| Engine | Chromium 99, embedded (Adobe CEP 12) | Chrome 151, standalone |
| Tablet | Wacom Intuos Pro L1, Bluetooth, WTabletServicePro, Windows Ink on | same |
| OS | Windows 11 Pro 26200 | same |
| Page | devicePixelRatio 1, single monitor | same |
Same tablet, same driver, same machine, same session. Only the engine differs.
| Behaviour | A (Chromium 99) | B (Chrome 151) | |
|---|---|---|---|
| 1 | Compatibility mouse events stop during a pen drag | yes — 49 / 0 | yes — 1034 / 2 |
| 2 | A pen drag can be cancelled out from under you | (prevented by CSS) | yes — every stroke, under 0.5 s |
| 3 | Hover mouse events echo the pen exactly, 1:1 | yes — 1294 / 1294 | yes — 82 / 82 |
| 4 | The cursor does follow the pen while hovering | yes — max Δ 0 px | yes — max Δ 0 px |
| 5 | maxTouchPoints is 0 with a working pen | yes | yes |
| — | Wheel coordinates land nowhere near the pen | yes — ~700 px off | no — 1 px |
That last row is the one to be careful with, and it comes last for that reason.
1. Compatibility mouse events stop during a pen drag#
Split one session by whether a button was held:
Chromium 99 (CEP) pointermove mousemove from the browser
hovering 1294 1294
pen down, dragging 49 0
Chrome 151 pointermove mousemove from the browser
hovering 82 82
pen down, dragging 1034 2 (4 strokes)Also zero mousedown and zero mouseup for the press, on both engines. So a UI
built on DOM mouse events sees a stroke that never starts, or starts and never
moves — while the pointer stream is complete and correct throughout. The
hovering rows are the control: while the pen hovers, the echo is perfect. It is
specifically the press that turns it off.
This is the one most likely to bite you today, and it is engine-current — not a legacy quirk.
Fix, without rewriting an existing mouse-driven UI onto pointer events. Each pointer event issues a ticket; the compatibility event the browser normally sends immediately afterwards redeems it; a ticket still unredeemed one task later is an event the browser never sent, and only those get synthesised:
const pending = { down: [], move: [], up: [] };
function backfill(kind, source) {
const ticket = { redeemed: false };
pending[kind].push(ticket);
setTimeout(() => {
const i = pending[kind].indexOf(ticket);
if (i >= 0) pending[kind].splice(i, 1);
if (!ticket.redeemed) synthesize(kind, source); // the browser never sent it
}, 0);
}
function redeem(kind) {
const q = pending[kind];
if (q.length) { q.shift().redeemed = true; return true; }
return false; // no ticket outstanding: a genuine, independent mouse event
}Mark synthesised events (e.mySynthetic = true) and skip them on the way back
in, or they redeem their own tickets. When the browser behaves normally the
mechanism is completely inert — every ticket is redeemed, nothing is
synthesised, and there is no double handling to reason about.
2. A pen drag can be cancelled out from under you#
The Chrome 151 stroke above did not end with the pen lifting. It ended like this:
stroke [pen id=2] 248 ms ended by pointercancel
pointermove (buttons held) = 7
mousemove = 0
mousedown = 0 mouseup = 0A quarter of a second in, the browser took the drag away as a pan gesture and
fired pointercancel. My test page set no touch-action.
That is the documented cause, so I ran it as a controlled A/B: six identical strips on one page, differing in one CSS declaration. Two long strokes on each condition, same pen, same session:
touch-action strokes cancelled stroke length pointermoves
auto (default) 2 2 480 ms, 131 ms 6, 6
none 2 0 9231 ms, 8842 ms 513, 509Not a marginal effect. Under the default the drag never survived half a second;
with touch-action: none both strokes ran until the pen lifted, seventy times
longer, and delivered eighty-five times the events.
Two consequences worth handling:
/* opt the draggable surfaces out of the gesture */
.canvas, .slider, .picker { touch-action: none; }/* a cancelled pointer never produces a mouse-up, so a drag handler stays
armed and the next stray move keeps painting */
el.addEventListener('pointercancel', e => endDragAsIfMouseUp(e));
/* and take the capture on press, so leaving the element does not end the drag */
el.addEventListener('pointerdown', e => el.setPointerCapture(e.pointerId));This is the “the slider dies half-way through a stroke” bug. It reproduced spontaneously on a page that was not even trying to trigger it, which is worth sitting with: the failure needs no unusual input, no gesture, no edge case — only a pen, a drag, and a surface that forgot to opt out.
3. “Which input moved last” cannot be answered by event order#
Suppose you want to know whether the pen or the mouse is the thing currently pointing. The obvious approach — remember both streams, compare timestamps or a sequence counter, most recent wins — cannot work.
Both engines mirror each pen pointermove with a compatibility mousemove at
the same coordinates, one step later. Counting hover moves with no button held:
Chromium 99 pointer = 1294 mouse = 1294
Chrome 151 pointer = 82 mouse = 82Exactly equal, because each is the other’s echo. So the mouse stream’s newest event is always newer than the pen’s, and a last-one-wins comparison concludes “the mouse moved last” every single time — including while the pen is the only thing being touched. The branch you wrote for the pen never runs.
This is not a bug. Compatibility mouse events are specified behaviour. The bug is reading them as evidence of an independent mouse.
Fix. Ask pointerType, not the clock:
let lastPointer = null;
document.addEventListener('pointermove', e => {
lastPointer = { x: e.clientX, y: e.clientY, type: e.pointerType };
}, true);
// worth handling pointerover too: a pen entering in hover reports it
// before it reports any move
const penIsPointing = () => lastPointer && lastPointer.type === 'pen';No bookkeeping between streams is needed, because moving the mouse emits a
pointermove of its own with pointerType: "mouse" — lastPointer stops being
a pen the moment the mouse moves. The question answers itself.
4. The cursor does follow the pen while hovering#
The received wisdom — I have read it in several places, and built on it myself before measuring — is that tablet drivers only move the Windows cursor when the pen touches down, so while the pen hovers the cursor stays parked wherever the mouse was last left.
On this hardware it is simply false, on both engines. Pairing every pointer move with the mouse move that echoed it, inside 50 ms:
Chromium 99 n = 1294 max |dx| = 0 max |dy| = 0
Chrome 151 n = 82 max |dx| = 0 max |dy| = 0Zero divergence, not “small”. The cursor tracks the pen exactly, hover included.
This cost me a round of work: I had written a fix for a divergence that does not occur, and the real defect was elsewhere. If your symptom is “it acts on the wrong place under the pen”, measure the two positions before assuming they differ — the answer changes which bug you are chasing.
5. maxTouchPoints is not a pen-capability signal#
navigator.maxTouchPoints === 0on both engines, on a machine where the pen reports pointerType: "pen" with
pressure 0 – 0.57 and tilt −26 – 27 degrees, and produced a thousand hover
moves in one session. A working digitiser, invisible to the capability check.
Feature-detect window.PointerEvent and branch on pointerType at event time
instead.
Related: vendor troubleshooting advice for this class of problem is routinely
“turn Windows Ink off”. With Ink on, pointerType was correctly "pen",
pressure and tilt were populated, and everything above worked. Turning Ink off
makes the pen report as a mouse, which is precisely what removes your ability to
tell the two apart.
And one that did not reproduce#
I nearly published this as a general Chromium behaviour. It is not one.
On Chromium 99 embedded in CEP, with the pen hovering a control at
(103,391) — and the system cursor at that same point, see finding 4 — every
wheel notch arrived carrying:
clientX/clientY = (1, 464) and in a second run, (0, 385)The x pinned to the viewport’s left edge, the y wandering. elementFromPoint
resolved to a container nowhere near the control, so the feature silently did
nothing — not the wrong control, nothing, which is much harder to debug
because it looks like your handler never ran.
On Chrome 151, same tablet, same machine, same session: five pen-hover
notches, every one of them landing within 1 px of the pen, and
elementFromPoint at the wheel position returning the same element as at the
pen position every time. It does not reproduce.
So this is specific to that old embedded engine, not something to file against
Chromium. The plausible neighbourhood is that WM_MOUSEWHEEL on Win32
carries screen coordinates rather than client coordinates, unlike every
other mouse message, and something in that engine’s chain treated them as the
wrong space; other frameworks have hit the same class of bug
(Godot, DirectXTK). I did not confirm it.
Worth defending against anyway, if you ship into an embedded engine you do
not control. The defence is the same lastPointer from finding 3, and it costs
nothing on engines without the bug, because there the two positions agree:
function wheelPoint(e) {
if (lastPointer && lastPointer.type === 'pen') {
return { x: lastPointer.x, y: lastPointer.y };
}
return { x: e.clientX, y: e.clientY };
}How to measure this yourself#
Do not skip to a fix. Log one line per event and read the log:
- Every pointer and mouse event: type,
pointerType,pointerId,isPrimary,pressure,tiltX/tiltY, coordinates, target, and a flag for events you synthesised. - Every wheel event:
deltaY,deltaMode,wheelDeltaY, the event’s own coordinates — and then what your handler decided to use, and what that resolved to.
That last part separates “the pen position was never used” from “it was used and still resolved wrong”. The event alone cannot tell you which, and they have different fixes.
Three things to summarise across the log, because they answer the questions you actually have:
- Hover move counts per device id — does the driver report hover at all.
- Maximum divergence between the pointer and mouse positions, paired in time — do the two streams actually disagree, or did you assume it.
- Counts split by whether a button was held — this is what exposes finding 1, and it is invisible in a total.
Two traps I fell into while building the harness, both of which produced confident wrong numbers:
Scope your counters to one stroke. My first version bucketed mouse moves
using a penDown flag set on pointerdown and cleared on pointerup. A stroke
ended in pointercancel instead, the flag stuck on, and every subsequent hover
move was counted as a drag — reporting 107 compatibility events during a drag
that actually had none. Open the counter on pointerdown, close it on
pointerup and pointercancel, and bucket from e.buttons on the event
itself rather than from any flag you maintain.
Size a ring buffer for the noisiest event. Mine kept the last 600 rows, and a few seconds of scrolling evicted every pointer row before I could read them. Keep cumulative counters separately; they stay correct after the raw rows roll off.