Drag like a physical object

Dismiss on velocity, damp past edges, ignore extra touches.

Emil Kowalski@emilkowalski
You can now swipe toasts to dismiss in Linear. Using Sonner under the hood
Jan 29, 2025Demo 1 ↓View on X ↗
Emil Kowalskiemilkowal.ski
If the swipe amount is greater than the threshold or velocity is higher than in this case 0.11, I remove the toast.
Building a toast componentDemo 1 ↓Read article ↗
Emil Kowalskiemilkowal.ski
Building a drawer componentDemo 2 ↓Read article ↗
Emil Kowalskiemilkowal.ski
When you drag upwards while already at the top of the drawer, the drawer will damp the drag, meaning the more you drag, the less the drawer will move.
Building a drawer componentDemo 2 ↓Read article ↗
Emil Kowalskiemilkowal.ski
Instead of just not allowing the toast to be dragged upwards, you can still drag it, but it will slow down and eventually stop.
Building a toast componentDemo 2 ↓Read article ↗
Emil Kowalskiemilkowal.ski
Once I start dragging, I set the toast to capture all future pointer events. This ensures that even if the mouse or your thumb moves outside the toast while dragging, the toast remains the target of the pointer events.
Building a toast componentDemo 3 ↓Read article ↗
Emil Kowalskiemilkowal.ski
We simply ignore all touches after the initial one until the user releases to prevent this
Building a drawer componentDemo 3 ↓Read article ↗

From emil-design-eng and his Sonner and Vaul write-ups.

A flick is enough

Flick each toast right, short and fast.

Distance onlyBad
Invoice sentSwipe right to dismiss
0px / 0ms = 0.00 px/ms
Distance or velocityGood
Invoice sentSwipe right to dismiss
0px / 0ms = 0.00 px/ms
const velocity = Math.abs(swipeAmount) / timeTaken;

if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
  dismiss();
} else {
  snapBack();
}

Damp past the edge

Pull each sheet up past its top, then let go.

Hard stopBad
Payment details
finger 0px · sheet 0px
DampedGood
Payment details
finger 0px · sheet 0px
const damp = (overshoot, size, c = 0.55) =>
  size * (1 - 1 / ((overshoot * c) / size + 1));

const y = dragY >= 0 ? dragY : -damp(-dragY, stageHeight);
sheet.style.transform = `translate3d(0, ${y}px, 0)`;

Hold on to the first pointer

Drag each knob right, fast, or simulate a second finger.

Last touch winsBad
gap 0px
First touch onlyGood
gap 0px
el.addEventListener("pointerdown", (e) => {
  if (activeId !== null) return;
  activeId = e.pointerId;
  el.setPointerCapture(e.pointerId);
});

Why it matters, pros and cons

Why it matters

Speed carries intent: a fast flick means “go away”. A hard clamp feels like a bug.

Pros

  • A 60px flick dismisses; no long drag.
  • Damping signals the end without a jolt.
  • Capture keeps the drag alive off-element.

Cons

  • Too low a threshold dismisses by accident.
  • Damping can hide a limit a hard stop shows.
  • Pinch and rotate need the extra touches.