# Give Drag Gestures Real Physics

**SHOULD** · **ID:** `interactions-drag-physics` · **Category:** interactions
**Source:** [Emil Kowalski](https://emilkowalski.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-drag-physics

> SHOULD: Give drags real physics: dismiss on velocity (`Math.abs(distance) / elapsedMs > ~0.11`) rather than a distance threshold, damp past boundaries (`over * limit / (over + limit)`), call `setPointerCapture(e.pointerId)` on drag start, and ignore extra pointers once a drag owns one.

Dismiss on velocity, damp at boundaries, capture the pointer, and ignore extra touch points mid-drag

Four mechanics turn a drag from a hit-test into something that feels like an object. Velocity dismissal: measure `Math.abs(dx) / elapsedMs` between pointer moves and dismiss above roughly 0.11 px/ms, so a quick flick works and users do not have to haul the sheet across the screen. Damping: past a natural edge, apply rising resistance (`over * limit / (over + limit)`) rather than a hard clamp — real things slow before they stop, and an invisible wall reads as a bug. `setPointerCapture(e.pointerId)` on drag start keeps the pointer stream flowing to your element even after the pointer leaves its bounds, which is exactly what a fast flick does. And once a drag owns a pointer, bail out of further `pointerdown` handlers so a second finger cannot re-anchor the origin and teleport the element.

## Bad — do not do this

`interactions-drag-physics-bad`

```tsx
import { useRef, useState } from 'react';

const DISTANCE_THRESHOLD = 140;
const LIMIT = 160;

export function DragPhysicsBad() {
  const startRef = useRef<number | null>(null);
  const [x, setX] = useState(0);
  const [dragging, setDragging] = useState(false);
  const [dismissed, setDismissed] = useState(false);
  const [log, setLog] = useState('Flick the card away.');

  const reset = () => {
    setX(0);
    setDismissed(false);
    setLog('Flick the card away.');
  };

  return (
    <div className="w-full max-w-sm">
      <div className="bg-card border border-border rounded-lg p-4">
        <p className="text-xs text-muted-foreground mb-3">
          Drag the card right to dismiss. A quick flick is not enough — it only goes if you physically haul it past
          140px. Drag left and it hits an invisible wall.
        </p>

        <div className="relative h-28 overflow-hidden rounded-md border border-border bg-muted">
          {dismissed ? (
            <div className="flex h-full items-center justify-center">
              <button
                onClick={reset}
                className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground"
              >
                Bring it back
              </button>
            </div>
          ) : (
            <div
              onPointerDown={(e) => {
                // No capture, and no guard: a second finger landing mid-drag re-anchors
                // the origin and the card teleports.
                startRef.current = e.clientX - x;
                setDragging(true);
              }}
              onPointerMove={(e) => {
                if (startRef.current === null) return;
                const next = e.clientX - startRef.current;
                // Hard clamp: an invisible wall instead of resistance.
                setX(Math.max(-LIMIT, Math.min(LIMIT, next)));
              }}
              onPointerLeave={() => {
                // Without setPointerCapture, the pointer stream stops the instant it
                // leaves the card — a fast flick simply loses its own drag.
                if (startRef.current === null) return;
                startRef.current = null;
                setDragging(false);
                setX(0);
                setLog('Pointer left the card mid-drag. Drag lost, snapped back.');
              }}
              onPointerUp={() => {
                if (startRef.current === null) return;
                startRef.current = null;
                setDragging(false);
                // Distance is the only thing that counts. Velocity is never measured.
                if (Math.abs(x) > DISTANCE_THRESHOLD) {
                  setDismissed(true);
                } else {
                  setX(0);
                  setLog(`Travelled ${Math.round(Math.abs(x))}px of ${DISTANCE_THRESHOLD}px. Snapped back.`);
                }
              }}
              style={{ transform: `translate3d(${x}px, 0, 0)` }}
              className={`absolute inset-2 flex cursor-grab touch-none select-none items-center rounded-md border border-border bg-card px-4 ${
                dragging ? '' : 'transition-transform duration-200'
              }`}
            >
              <span className="text-sm text-foreground">Drag me right</span>
            </div>
          )}
        </div>

        <dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
          <div>
            <dt className="text-muted-foreground">distance</dt>
            <dd className="font-mono text-foreground">
              {Math.round(x)}px / {DISTANCE_THRESHOLD}px
            </dd>
          </div>
          <div>
            <dt className="text-muted-foreground">velocity</dt>
            <dd className="font-mono text-error">not measured</dd>
          </div>
        </dl>
        <p className="mt-2 text-xs text-muted-foreground">{log}</p>
      </div>
      <p className="text-xs text-error mt-4">
        Distance-only threshold, no pointer capture, hard clamp — a flick loses its drag and the card snaps back
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-drag-physics-good`

```tsx
import { useRef, useState } from 'react';

const DISTANCE_THRESHOLD = 140;
const VELOCITY_THRESHOLD = 0.11; // px per ms
const LIMIT = 160;

/** Rubber band: the further past the edge you pull, the less the card moves. */
function damp(over: number) {
  return (over * LIMIT) / (over + LIMIT);
}

export function DragPhysicsGood() {
  const pointerRef = useRef<number | null>(null);
  const startRef = useRef(0);
  const lastRef = useRef({ x: 0, t: 0 });
  const [x, setX] = useState(0);
  const [dragging, setDragging] = useState(false);
  const [velocity, setVelocity] = useState(0);
  const [dismissed, setDismissed] = useState(false);
  const [log, setLog] = useState('Flick the card away — a quick swipe is enough.');

  const reset = () => {
    setX(0);
    setVelocity(0);
    setDismissed(false);
    setLog('Flick the card away — a quick swipe is enough.');
  };

  const end = (dismiss: boolean, message: string) => {
    pointerRef.current = null;
    setDragging(false);
    if (dismiss) setDismissed(true);
    else setX(0);
    setLog(message);
  };

  return (
    <div className="w-full max-w-sm">
      <div className="bg-card border border-border rounded-lg p-4">
        <p className="text-xs text-muted-foreground mb-3">
          Flick the card right — speed alone dismisses it, no need to haul it across. Drag left and it gives, then
          resists harder the further you pull.
        </p>

        <div className="relative h-28 overflow-hidden rounded-md border border-border bg-muted">
          {dismissed ? (
            <div className="flex h-full items-center justify-center">
              <button
                onClick={reset}
                className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground"
              >
                Bring it back
              </button>
            </div>
          ) : (
            <div
              onPointerDown={(e) => {
                // Multi-touch protection: once a drag owns a pointer, extra touch points are ignored.
                if (pointerRef.current !== null) return;
                pointerRef.current = e.pointerId;
                // Pointer capture keeps the stream coming even when the pointer leaves the card.
                e.currentTarget.setPointerCapture(e.pointerId);
                startRef.current = e.clientX - x;
                lastRef.current = { x: e.clientX, t: performance.now() };
                setDragging(true);
              }}
              onPointerMove={(e) => {
                if (pointerRef.current !== e.pointerId) return;

                const now = performance.now();
                const dt = now - lastRef.current.t;
                if (dt > 0) {
                  setVelocity(Math.abs(e.clientX - lastRef.current.x) / dt);
                  lastRef.current = { x: e.clientX, t: now };
                }

                const raw = e.clientX - startRef.current;
                // Rising resistance past the boundary instead of a hard stop.
                const over = Math.abs(raw) - LIMIT;
                const next = over > 0 ? Math.sign(raw) * (LIMIT + damp(over)) : raw;
                setX(next);
              }}
              onPointerUp={(e) => {
                if (pointerRef.current !== e.pointerId) return;
                const flicked = velocity > VELOCITY_THRESHOLD && x > 0;
                const hauled = x > DISTANCE_THRESHOLD;
                end(
                  flicked || hauled,
                  flicked
                    ? `Dismissed on velocity: ${velocity.toFixed(2)} px/ms > ${VELOCITY_THRESHOLD}.`
                    : hauled
                      ? `Dismissed on distance: ${Math.round(x)}px.`
                      : `Too slow (${velocity.toFixed(2)} px/ms) and too short. Sprung back.`,
                );
              }}
              onPointerCancel={() => end(false, 'Gesture cancelled. Sprung back cleanly.')}
              style={{ transform: `translate3d(${x}px, 0, 0)`, touchAction: 'none' }}
              className={`absolute inset-2 flex cursor-grab select-none items-center rounded-md border border-border bg-card px-4 ${
                dragging ? '' : 'transition-transform duration-300'
              }`}
            >
              <span className="text-sm text-foreground">Flick me right</span>
            </div>
          )}
        </div>

        <dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
          <div>
            <dt className="text-muted-foreground">distance</dt>
            <dd className="font-mono text-foreground">
              {Math.round(x)}px / {DISTANCE_THRESHOLD}px
            </dd>
          </div>
          <div>
            <dt className="text-muted-foreground">velocity</dt>
            <dd className="font-mono text-success">
              {velocity.toFixed(2)} / {VELOCITY_THRESHOLD} px/ms
            </dd>
          </div>
        </dl>
        <p className="mt-2 text-xs text-muted-foreground">{log}</p>
      </div>
      <p className="text-xs text-success mt-4">
        Velocity dismissal, pointer capture, damped boundary and a single-pointer guard — a flick is enough
      </p>
    </div>
  );
}
```

## References

- [Emil Kowalski: review-animations STANDARDS.md](https://github.com/emilkowalski/skills/blob/main/skills/review-animations/STANDARDS.md)
- [MDN: Element.setPointerCapture()](https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture)
