Emerging Interfaces

Pointer Events Tutorial: Reading Pressure, Tilt and Twist From a Stylus in the Browser

One API for mouse, touch and pen — plus the coalesced-events call that stops your strokes looking like polygons, and the pressure values that lie.

Every browser on every platform has supported full stylus input for years. Pressure, tilt, twist, hover, eraser buttons — all of it is in one standard API, and the overwhelming majority of web drawing tools use none of it beyond x and y.

This is how to use the rest.

Nono Martínez Alonso demonstrating pressure-sensitive freehand stroke rendering in the browser.

Why Pointer Events and not the others

Three input APIs exist, and the history explains the mess. Mouse Events came first and understands one cursor with buttons. Touch Events was added for phones and understands multiple fingers but not pressure or tilt in any portable way. Pointer Events unifies both and adds everything a pen has.

The decisive advantage is that one set of handlers serves mouse, touch and pen, with pointerType telling you which you got. You stop writing two code paths that drift apart.

const canvas = document.getElementById('c');

canvas.addEventListener('pointerdown', onDown);
canvas.addEventListener('pointermove', onMove);
canvas.addEventListener('pointerup', onUp);
canvas.addEventListener('pointercancel', onUp);

Handle pointercancel. It fires when the browser takes the gesture away — a system gesture, a palm detected, the page scrolling — and if you only listen for pointerup you will leave strokes open forever.

Step 1 — The CSS line that everything depends on

#c {
  touch-action: none;
}

Without this, the browser treats your pen and finger input as scroll and zoom gestures, and your pointermove handlers stop firing mid-stroke. This is the single most common reason a canvas “works with a mouse and not with a pen,” and it is a one-line CSS fix rather than a JavaScript problem.

Use touch-action: none on the drawing surface only, never on a scrolling container.

Step 2 — What the properties actually give you

function onMove(e) {
  console.log({
    type: e.pointerType,   // 'mouse' | 'pen' | 'touch'
    pressure: e.pressure,  // 0..1
    tiltX: e.tiltX,        // -90..90 degrees
    tiltY: e.tiltY,        // -90..90 degrees
    twist: e.twist,        // 0..359 degrees
    width: e.width,        // contact geometry
    height: e.height,
    buttons: e.buttons,
  });
}

Now the honest part, because the spec and the hardware do not agree as neatly as the list suggests.

pressure is 0 to 1, and its defaults will catch you. For a mouse, it is 0.5 while a button is held and 0 otherwise — not zero pressure, half. For touch, it is 0.5 on most hardware, because capacitive panels do not measure force. A pen that reports no pressure also gives 0.5. So:

const hasRealPressure = e.pointerType === 'pen' && e.pressure !== 0.5;

Always design the stroke so it looks right at a constant 0.5, then let real pressure modulate it. The reverse — building around pressure and discovering mouse users get a uniform half-weight line — is the usual mistake.

tiltX / tiltY are two angles, not a vector. They are the tilt from vertical in the X–Z and Y–Z planes respectively, each −90 to 90. To get an azimuth, which is almost always what you want for a chisel or brush nib:

const azimuth = Math.atan2(e.tiltY, e.tiltX);        // direction of lean
const altitude = Math.hypot(e.tiltX, e.tiltY);       // how far from vertical

Note that the spec also defines altitudeAngle and azimuthAngle directly, but support is less even than for the tilt pair, so computing them is the portable route.

twist is barrel rotation and almost nothing reports it. Among widely available hardware only a few pens — the Surface Slim Pen among them — provide it; most report 0 permanently. Treat it as a bonus, never a requirement.

width and height are contact geometry and are the one useful signal for touch: a fingertip and a palm have very different contact ellipses, which is the basis of most palm rejection.

buttons carries the eraser. A pen’s eraser end or side button shows up in the bitmask, and handling it means the hardware affordance people already reach for actually does something.

Step 3 — Pointer capture, so strokes survive leaving the element

function onDown(e) {
  e.target.setPointerCapture(e.pointerId);
  startStroke(e);
}

Without capture, a stroke that leaves the canvas bounds stops receiving events and your line ends at the edge. With it, all events for that pointerId keep arriving at the element until release. Capture is released automatically on pointerup and pointercancel.

Also track pointerId. Multiple pens, or a pen plus fingers, produce interleaved event streams, and keeping per-id state is what lets you support two-handed input instead of one confused stroke.

Step 4 — getCoalescedEvents, the one that fixes jagged lines

This is the most valuable and least used call in the API.

The browser fires pointermove at most once per frame. A pen samples at 120–240 Hz or more. At 60 fps, the browser is throwing away two to three samples for every one you see — and that is why fast strokes come out as visible straight segments between widely-spaced points.

The samples are not actually lost:

function onMove(e) {
  const events = e.getCoalescedEvents?.() ?? [e];
  for (const p of events) {
    addPoint(p.clientX, p.clientY, p.pressure, p.tiltX, p.tiltY);
  }
}

You get every sample the hardware produced since the last frame, each with its own position, pressure and tilt. One line of code, and a fast stroke goes from a polygon to a curve.

The counterpart for output is requestAnimationFrame — collect coalesced points in the handler, render once per frame. Never draw inside the pointer handler; you will render several times per frame and jank.

Step 5 — Making pressure look like a pen

Mapping pressure directly to line width produces something that does not read as a brush, for two reasons worth understanding.

It is too linear. Human force perception is not, and neither is any real nib. A gamma curve is closer:

const w = minWidth + (maxWidth - minWidth) * Math.pow(p.pressure, 1.6);

Exponents between 1.3 and 2.0 feel pen-like; tune by drawing, not by reasoning.

It is too jittery. Raw pressure is noisy, especially at the start and end of a stroke where the nib is barely loaded. Smooth it:

smoothed = smoothed * 0.7 + p.pressure * 0.3;

And handle the ends deliberately. A real stroke tapers in and out; pressure data often starts mid-range because the first sample arrives after contact is established. Force the first and last few points toward zero width and the stroke immediately looks hand-made.

Step 6 — Feature detection and fallback

if (!window.PointerEvent) {
  // Pre-2016 browsers only; fall back to mouse + touch handlers.
}

const finePointer = matchMedia('(pointer: fine)').matches;
const canHover = matchMedia('(hover: hover)').matches;

The media queries are the right tool for layout decisions — hit targets, hover affordances — while pointerType on the event is the right tool for per-stroke behaviour. Both, because a device can have several input types and the user can switch mid-session.

Where to go next

  • perfect-freehand solves variable-width stroke outlines properly, generating a filled polygon rather than a stroked path. It is the standard answer and worth reading even if you write your own.
  • Hover is an unused channel. A pen reports position before it touches the surface, which is enough for a brush preview showing exactly where and how wide the mark will land. Hardware has supported it for twenty years; almost no web tool uses it.
  • Tilt as a real parameter, not a gimmick. A chisel nib whose width follows azimuth, or a shading mode that engages past a tilt threshold, are the two cases where tilt earns its place.
  • Palm rejection from width/height and pointerType. Discard touch pointers with a large contact ellipse while a pen is active.