#779·sonner

Toast swipe-to-dismiss never resets on pointercancel, only pointerup

Author: MILLERMARRUCreated Aug 13, 2026Updated Aug 13, 2026

Description

A toast's swipe-to-dismiss handling only listens for onPointerDown/onPointerUp/onPointerMove, there's no onPointerCancel anywhere in src/index.tsx.

typescript
onPointerDown={(event) => {
  // ...
  (event.target as HTMLElement).setPointerCapture(event.pointerId);
  // ...
  setSwiping(true);
  pointerStartRef.current = { x: event.clientX, y: event.clientY };
}}
onPointerUp={() => {
  // ... resets isSwiped/setSwiping(false)/swipeDirection/etc, or triggers dismiss
}}
onPointerMove={(event) => {
  // ... updates --swipe-amount-x/--swipe-amount-y based on pointerStartRef
}}

Per the Pointer Events spec, pointercancel fires instead of pointerup when the browser decides it can no longer generate events for a pointer (an OS-level gesture taking over the touch, another app/overlay stealing the interaction, some multi-touch conflicts). None of the swiping state (isSwiping, swipeDirection, pointerStartRef.current, the --swipe-amount-x/--swipe-amount-y CSS custom properties) gets reset when that happens, since only onPointerUp clears it.

A concrete recent precedent for this exact bug class: @vueuse/core's useDraggable had the identical gap (listened for pointerup but not pointercancel, leaving isDragging stuck true until the next full down/up cycle), fixed in vueuse/vueuse#5550.

Reproduction

  1. Trigger a toast on a touch device.
  2. Start a swipe gesture on it (pointerdown + a few pointermoves past the point where swipeDirection locks in).
  3. Interrupt the gesture with something that fires pointercancel before a pointerup (a real one: the browser deciding the touch is actually a page scroll/pan and taking it over mid-gesture; synthetically, dispatch a PointerEvent('pointercancel', { pointerId }) for the same pointer instead of pointerup).

Expected: the toast returns to its normal, non-swiping state. Actual: isSwiping/data-swiping and the swipe-offset custom properties stay applied to the toast (visually and interactively "stuck" mid-swipe), since nothing ever ran the onPointerUp reset logic for that pointer.

Suggested fix

Add an onPointerCancel handler alongside the existing onPointerUp one, running the same reset path (pointerStartRef.current = null; setIsSwiped(false); setSwiping(false); setSwipeDirection(null);, skipping the dismiss-threshold logic since a cancelled gesture shouldn't dismiss the toast).