# Respect Where the User Grabbed

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

> MUST: Store the pointer's offset within the element at `pointerdown` and subtract it on every `pointermove` so the element stays glued to the spot the user grabbed. Never re-centre the element under the cursor on grab — it teleports on the first frame and the illusion of holding an object dies.

Store the pointer's offset within the element on pointerdown and subtract it on every move

The cheapest way to write a drag is to set the element's position to the pointer's position, which silently centres it under the cursor. The result is a visible teleport at the instant of grab: you press the bottom edge of a card and the card jumps up so its middle is under your finger. Nothing in the physical world does this, and the illusion of holding an object dies in the first frame — before any of the release physics gets a chance to matter. The fix is two lines and no library: at pointerdown, record `e.clientY - element.getBoundingClientRect().top` (and the same for x), then on every pointermove subtract that offset from the pointer position. The element stays glued to the exact spot you took hold of. This is grab-time positioning, and no other rule in the corpus covers it: "Clean Drag Interactions" is about text selection and inert during the drag, and "Give Drag Gestures Real Physics" is about what happens on release. Between them sits the very first frame, which is the one users feel most sharply.

## Rule snippet

```tsx
const r = el.getBoundingClientRect();
const offset = { x: e.clientX - r.left, y: e.clientY - r.top };
// pointermove:
el.style.translate = `${e.clientX - offset.x}px ${e.clientY - offset.y}px`;
```

## Bad — do not do this

`interactions-grab-offset-bad`

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

const CARD_H = 64;
const TRACK_H = 200;

/**
 * The lazy drag: position the card at the pointer. Which silently means
 * "centre the card on the pointer" — so the instant you grab the card's edge,
 * it teleports to put its middle under your finger. The illusion of holding
 * an object dies in frame one, before any release physics gets a chance.
 */
export function GrabOffsetBad() {
  const trackRef = useRef<HTMLDivElement>(null);
  const pointerRef = useRef<number | null>(null);
  const [y, setY] = useState(TRACK_H / 2 - CARD_H / 2);
  const [dragging, setDragging] = useState(false);
  const [jump, setJump] = useState(0);

  const clamp = (v: number) => Math.min(TRACK_H - CARD_H, Math.max(0, v));

  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">
          Grab this card by its <span className="text-foreground">top or bottom edge</span> and watch it
          snap its centre to your cursor before it starts following.
        </p>

        <div
          ref={trackRef}
          style={{ height: TRACK_H }}
          className="relative overflow-hidden rounded-md border border-border bg-muted"
        >
          <div
            onPointerDown={(e) => {
              if (pointerRef.current !== null) return;
              const track = trackRef.current;
              if (!track) return;
              pointerRef.current = e.pointerId;
              e.currentTarget.setPointerCapture(e.pointerId);

              const trackTop = track.getBoundingClientRect().top;
              // Where they grabbed is never recorded. The card is simply centred
              // on the pointer — a visible teleport of up to half the card's height.
              const next = clamp(e.clientY - trackTop - CARD_H / 2);
              setJump(Math.abs(next - y));
              setY(next);
              setDragging(true);
            }}
            onPointerMove={(e) => {
              if (pointerRef.current !== e.pointerId) return;
              const trackTop = trackRef.current?.getBoundingClientRect().top ?? 0;
              setY(clamp(e.clientY - trackTop - CARD_H / 2));
            }}
            onPointerUp={() => {
              pointerRef.current = null;
              setDragging(false);
            }}
            onPointerCancel={() => {
              pointerRef.current = null;
              setDragging(false);
            }}
            style={{ transform: `translate3d(0, ${y}px, 0)`, height: CARD_H, touchAction: 'none' }}
            className="absolute inset-x-2 flex cursor-grab select-none items-center rounded-md border border-border bg-card px-4"
          >
            <div>
              <p className="text-sm text-foreground">Drag me by an edge</p>
              <p className="text-xs text-muted-foreground">I will jump to centre myself</p>
            </div>
          </div>
        </div>

        <p className="mt-3 text-xs" aria-live="polite">
          <span className="text-muted-foreground">Teleport on grab: </span>
          <span className="font-mono text-destructive">{Math.round(jump)}px</span>
          {dragging ? '' : jump > 0 ? ' — the card moved before you did.' : ''}
        </p>
      </div>
      <p className="text-xs text-destructive mt-4">
        Centres the card on the pointer at pointerdown — it visibly teleports out from under your finger
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-grab-offset-good`

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

const CARD_H = 64;
const TRACK_H = 200;

/**
 * Two lines and no library: record the pointer's offset *within* the element at
 * pointerdown, then subtract it on every move. The card stays glued to the exact
 * spot you took hold of, and the first frame of the drag moves nothing at all.
 */
export function GrabOffsetGood() {
  const trackRef = useRef<HTMLDivElement>(null);
  const pointerRef = useRef<number | null>(null);
  const grabOffsetRef = useRef(0);
  const [y, setY] = useState(TRACK_H / 2 - CARD_H / 2);
  const [dragging, setDragging] = useState(false);
  const [grabOffset, setGrabOffset] = useState<number | null>(null);
  const [jump, setJump] = useState(0);

  const clamp = (v: number) => Math.min(TRACK_H - CARD_H, Math.max(0, v));

  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">
          Grab this card <span className="text-foreground">anywhere</span> — an edge, a corner, the middle.
          It stays exactly where you took hold of it.
        </p>

        <div
          ref={trackRef}
          style={{ height: TRACK_H }}
          className="relative overflow-hidden rounded-md border border-border bg-muted"
        >
          <div
            onPointerDown={(e) => {
              if (pointerRef.current !== null) return;
              const track = trackRef.current;
              if (!track) return;
              pointerRef.current = e.pointerId;
              // Pointer capture keeps the stream flowing when the pointer leaves the card.
              e.currentTarget.setPointerCapture(e.pointerId);

              // Where inside the card did they grab? Remember it.
              const rect = e.currentTarget.getBoundingClientRect();
              grabOffsetRef.current = e.clientY - rect.top;
              setGrabOffset(grabOffsetRef.current);

              const trackTop = track.getBoundingClientRect().top;
              const next = clamp(e.clientY - trackTop - grabOffsetRef.current);
              setJump(Math.abs(next - y)); // zero, by construction
              setY(next);
              setDragging(true);
            }}
            onPointerMove={(e) => {
              if (pointerRef.current !== e.pointerId) return;
              const trackTop = trackRef.current?.getBoundingClientRect().top ?? 0;
              // Subtract the grab offset on every move — this is the whole trick.
              setY(clamp(e.clientY - trackTop - grabOffsetRef.current));
            }}
            onPointerUp={() => {
              pointerRef.current = null;
              setDragging(false);
            }}
            onPointerCancel={() => {
              pointerRef.current = null;
              setDragging(false);
            }}
            style={{ transform: `translate3d(0, ${y}px, 0)`, height: CARD_H, touchAction: 'none' }}
            className="absolute inset-x-2 flex cursor-grab select-none items-center rounded-md border border-border bg-card px-4"
          >
            <div>
              <p className="text-sm text-foreground">Drag me anywhere</p>
              <p className="text-xs text-muted-foreground">I stay glued to your finger</p>
            </div>
          </div>
        </div>

        <dl className="mt-3 grid grid-cols-2 gap-2 text-xs" aria-live="polite">
          <div>
            <dt className="text-muted-foreground">grab offset</dt>
            <dd className="font-mono text-foreground">
              {grabOffset === null ? '—' : `${Math.round(grabOffset)}px from top`}
            </dd>
          </div>
          <div>
            <dt className="text-muted-foreground">teleport on grab</dt>
            <dd className="font-mono text-success">{Math.round(jump)}px</dd>
          </div>
        </dl>
        <p className="mt-2 text-xs text-muted-foreground">
          {dragging
            ? 'The point you grabbed is still under your pointer.'
            : 'Grab it low, grab it high — the offset changes, the teleport stays at 0px.'}
        </p>
      </div>
      <p className="text-xs text-success mt-4">
        grabOffset = e.clientY − rect.top at pointerdown, subtracted on every move — no teleport
      </p>
    </div>
  );
}
```

## References

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