← Writing
5 min read

Gesture-driven motion: making things follow your finger

The difference between a sheet that responds to a drag and one that plays an animation at you. Velocity, projection, rubber-banding, and why interruption is the whole game.

  • technique
  • patterns

There is a category of interface motion that feels categorically better than the rest, and it is not the most elaborate. It is the motion that tracks your finger.

Drag a bottom sheet down and it moves with you. Release halfway and it decides whether to snap back or carry on, based on how fast you were moving. At no point does it stop responding to say “I am busy playing an animation.”

That last property is the whole thing. Everything else is implementation.

One-to-one first

The baseline is that during a drag, the element follows the pointer exactly. Not eased, not lagged, not smoothed: one pixel of finger, one pixel of movement.

let startY = 0;
let currentY = 0;

sheet.addEventListener('pointerdown', (e) => {
  startY = e.clientY;
  sheet.setPointerCapture(e.pointerId);
  sheet.style.transition = 'none';   // kill any running transition
});

sheet.addEventListener('pointermove', (e) => {
  if (!sheet.hasPointerCapture(e.pointerId)) return;
  currentY = Math.max(0, e.clientY - startY);
  sheet.style.transform = `translateY(${currentY}px)`;
});

Two details doing real work. setPointerCapture keeps events coming to the element even when the finger leaves its bounds. Without it, a fast drag detaches. And killing the transition on pointerdown is essential: a CSS transition still applied during a drag adds latency between finger and element, which is precisely the feeling you are trying to avoid.

Use Pointer Events rather than separate mouse and touch handlers. One code path covers mouse, touch, and pen.

Release is a decision, not an animation

When the finger lifts, something has to decide: does this dismiss, or snap back? The naive rule is position: past halfway, dismiss.

That rule feels wrong constantly, because it ignores intent. A short, fast flick clearly means “dismiss” even at 20% travel. A slow drag to 60% followed by a pause suggests hesitation.

So use both position and velocity:

sheet.addEventListener('pointerup', () => {
  const shouldDismiss =
    currentY > sheetHeight * 0.5 ||       // dragged far enough
    velocity > 0.5;                        // or thrown hard enough

  animateTo(shouldDismiss ? sheetHeight : 0, velocity);
});

Velocity in px/ms; 0.5 is a reasonable threshold to tune. Track it over the last few move events rather than the whole gesture. What matters is speed at the moment of release, not the average:

const samples = [];   // {y, t}
function trackVelocity(y) {
  const now = performance.now();
  samples.push({ y, t: now });
  while (samples.length > 5) samples.shift();
  const first = samples[0], last = samples[samples.length - 1];
  const dt = last.t - first.t;
  return dt > 0 ? (last.y - first.y) / dt : 0;
}

Projection: where would it have landed

The better version of the threshold question is not “how fast was it going” but “where would it end up if it kept going and decelerated naturally?”

// Exponential decay: distance travelled before coming to rest.
const projected = currentY + velocity * 100;   // 100 ≈ decay constant
const shouldDismiss = projected > sheetHeight * 0.5;

This unifies position and velocity into one number and matches intent remarkably well. A slow drag projects roughly where it already is. A flick projects far past it. The user’s mental model (“I threw it, so it should go”) is served by simulating exactly that.

Carry the velocity into the release animation

The most common way a good drag interaction gets ruined at the last moment: the release plays a fixed-duration animation from wherever the element is. The element is travelling at speed, then abruptly restarts on a fresh 300ms ease-out. There is a visible hitch exactly at the moment the interaction should feel most fluid.

The release animation must start at the velocity the drag ended with, which is a spring’s defining ability: it is a function of position and velocity rather than elapsed time. This is the case where springs beat curves, and it is not a close call.

If you must use a curve, approximate: scale duration inversely with velocity, so a fast release gets a short animation. It is a workaround, and it shows on close inspection, but it is much better than a constant duration.

Rubber-banding at the boundaries

When a drag pushes past a limit, like pulling a sheet up beyond its open position or scrolling past the top of a list, hard-stopping feels broken. The element should move, but resist:

function rubberBand(delta, dimension, constant = 0.55) {
  return (1 - 1 / (delta * constant / dimension + 1)) * dimension;
}

Movement decreases as you push further, asymptotically. The user gets feedback that the gesture registered and that they have hit a limit, in one motion. Then it springs back on release.

This is the single cheapest thing that makes a custom gesture feel native rather than homemade.

Details that separate good from adequate

Decide the axis early and commit. A vertical sheet inside a horizontal carousel needs to determine direction from the first few pixels, then lock. Elements that respond to both axes at once feel unsteady.

touch-action is not optional. Without touch-action: none on a draggable element, the browser’s own scrolling competes with your handler, and on iOS it usually wins. Set it as narrowly as possible (pan-y if you only handle horizontal drags) so native scrolling still works everywhere else.

Never block the main thread during a drag. Every pointer event is a frame. Fetching, parsing, or expensive rendering while a finger is down is instantly visible.

Provide a non-gesture path. Every gesture must have a button, a keyboard route, or both. Gestures are undiscoverable and inaccessible on their own. A swipe-to-dismiss sheet with no close button is unusable for a lot of people.

Why this matters more than it looks

Direct manipulation is the strongest illusion in interface design. When something tracks your finger and continues with the momentum you gave it, it stops reading as a picture of an object and starts reading as an object.

It is also unforgiving. A 100ms lag between finger and element is immediately felt, even by people who could not name what is wrong. Which is why the guidance here is mostly about removing things: no transition during the drag, no fixed duration on release, no work on the main thread.

The motion is not what you add. It is what you stop getting in the way of.