GODRICH

9 Mobile Touch Gestures That Follow Your Finger

Mobile touch gestures turn the distance and speed of a finger on glass into a change on screen.

Auto-plays · click a tile to jump to its section · all nine in one zip

They are ordered by how long a finger stays on the glass, not by how fashionable they are. The first is barely contact at all — two quick taps (01). Next comes a push that only counts when it reaches the end (02), then a finger that travels in a circle to set a value (03), a whole panel dragged aside (04), and a card whose verdict depends on how far it went (05). From the sixth on, where the finger starts is the whole point: begin at the screen edge and it means back (06), pull past the end of a list and resistance builds (07), lift from the bottom and a sheet catches on a step (08). Only the last one (09) needs two fingers. All nine take mouse and touch through the same pointerdown, pointermove, and pointerup handlers, and every gesture also has a button or an arrow key that does the same job.

01Double-tap zoom

Two quick taps zoom the photo in around the second tap point, and another pair zooms back out. The zoom center comes from feeding the tap coordinates straight into transformOrigin, while the plus and minus buttons walk the same array of steps.

dblclicktransform-originaria-live
photo.addEventListener('pointerdown', function (e) {
  var now = Date.now();
  if (now - lastTap < 320) {
    var r = photo.getBoundingClientRect();
    var x = e.clientX - r.left, y = e.clientY - r.top;
    pulse(x, y);
    go(at === STEPS.length - 1 ? 0 : at + 1, x, y);
    lastTap = 0;
    e.preventDefault();
  } else { lastTap = now; }
});

02Slide-to-unlock track

The handle has to reach the far end of the track before anything unlocks; let go halfway and it springs back. Grabbing it calls setPointerCapture, so the drag survives a finger that wanders off the track.

setPointerCapturetranslateXArrowRight
handle.addEventListener('pointerdown', function (e) {
  if (done) return;
  dragging = true; sx = e.clientX; x0 = x;
  handle.setPointerCapture(e.pointerId);
  handle.style.transition = 'none';
  e.preventDefault();
});
handle.addEventListener('pointermove', function (e) {
  if (!dragging) return;
  paint(x0 + (e.clientX - sx));
});

03Drag-to-turn value dial

Turning the round knob raises and lowers the value, and the gauge arc fills to the same angle. The angle is Math.atan2 between the dial center and the pointer, folded into a 270-degree range that starts at −135 degrees.

atan2stroke-dashoffsetArrowUp
function fromPointer(e) {
  var r = dial.getBoundingClientRect();
  var dx = e.clientX - (r.left + r.width / 2);
  var dy = e.clientY - (r.top + r.height / 2);
  var deg = Math.atan2(dy, dx) * 180 / Math.PI;
  var rel = deg - BASE;
  while (rel > 180) rel -= 360;
  while (rel < -180) rel += 360;
  if (rel < -SPAN / 2) rel = -SPAN / 2;
  if (rel > SPAN / 2) rel = SPAN / 2;
  setValue((rel + SPAN / 2) / SPAN * 100);
}

04Swipe tab switcher

The panel tracks the finger live, and the release decides: remaining distance and speed are read together to either finish the move or snap back. The thresholds are 44px of travel and 0.45px/ms of speed, and passing either one is enough.

translateXthresholdroving tabindex
function drop() {
  if (!dragging) return;
  dragging = false;
  track.style.transition = '';
  var dx = lastX - x0;
  if (dx < -44 || v < -.45) go(at + 1);
  else if (dx > 44 || v > .45) go(at - 1);
  else go(at);
}

05Swipe card deck

Dragging the card sideways tilts it while the PASS or SKIP stamp fades in, and crossing 64px on release locks the verdict and throws the card off screen. Tilt is the drag distance divided by nine, capped at twelve degrees.

rotatevelocityaria-label
top.addEventListener('pointermove', function (e) {
  if (!dragging) return;
  var dx = e.clientX - x0;
  var r = Math.max(-12, Math.min(12, dx / 9));
  top.style.transform = 'translateX(' + dx + 'px) rotate(' + r + 'deg)';
  top.querySelector('.cd__stamp--pass').style.opacity = Math.max(0, Math.min(1, dx / 64));
  top.querySelector('.cd__stamp--skip').style.opacity = Math.max(0, Math.min(1, -dx / 64));
});

06Edge swipe back

Only a drag that starts inside the leftmost 28px counts as going back; anything starting further in is ignored outright. The move commits when the pull passes 72% of a 55%-of-frame budget, and falls back otherwise.

translateXthresholdhistory
frame.addEventListener('pointerdown', function (e) {
  var r = frame.getBoundingClientRect();
  if (e.clientX - r.left > 28) return;
  dragging = true; x0 = e.clientX;
  frame.setPointerCapture(e.pointerId);
  prev.style.transition = 'none';
  cur.style.transition = 'none';
  e.preventDefault();
});

07Rubber-band boundary

Past the end of the list, only a third of the extra drag makes it to the screen, so finger and content drift apart, and that drift is the message: this is the end. Releasing snaps back on a bouncy easing curve.

overscrollresistancecubic-bezier
function paint(y) {
  var b = bounds();
  var yd = y;
  if (yd < b.top) yd = b.top + (yd - b.top) / 3;
  if (yd > b.bottom) yd = b.bottom + (yd - b.bottom) / 3;
  cur = y;
  inner.style.transform = 'translateY(' + (-yd) + 'px)';
  topEnd.classList.toggle('is-on', y < -6);
  bottomEnd.classList.toggle('is-on', y > b.bottom + 6);
}

08Drag-up bottom sheet

The sheet rides the finger and settles onto the nearest step when released, but a hard downward flick closes it regardless of where it was. Flick speed is measured between the last two events, with 0.5px/ms as the line.

translateYsnapEscape
function drop() {
  if (!dragging) return;
  dragging = false;
  sheet.style.transition = '';
  var cur2 = startPos + (lastY - startY);
  if (v0 > .5) { go(0); return; }
  if (v0 < -.5) { go(SNAPS.length - 1); return; }
  var best = 0, dist = 1e9;
  SNAPS.forEach(function (s, i) {
    var d = Math.abs(cur2 - s);
    if (d < dist) { dist = d; best = i; }
  });
  go(best);
}

09Pinch zoom

Two pointers are stored separately, and the ratio of the distance between them becomes scale while the change in their tilt becomes rotation. A mouse has no second finger, so the wheel zooms and a plain drag pans instead.

pointermapscalectrl+wheel
function pinchInfo() {
  var ks = Object.keys(ptrs);
  if (ks.length < 2) return null;
  var a = ptrs[ks[0]], b = ptrs[ks[1]];
  var r = frame.getBoundingClientRect();
  var ax = a.x - r.left, ay = a.y - r.top, bx = b.x - r.left, by = b.y - r.top;
  return {
    d: Math.hypot(ax - bx, ay - by),
    ang: Math.atan2(by - ay, bx - ax) * 180 / Math.PI
  };
}

Where it breaks — the trap

The real trap in gesture work turned out to have nothing to do with fingers: an element thrown off screen still counts toward document size. Card deck 05 flings a judged card away with translateX(230px), and long after that card is out of sight, its position keeps stretching the document. On a 320px-wide screen, one card stuck out more than 100px past the right edge, and the overflow: hidden on the surrounding stage did nothing about it. Being invisible and being excluded from layout size are different things. The fix was giving the deck itself overflow: hidden and a fixed size so the card is clipped the moment it leaves, and with that the changed-pixel area of the same animation dropped from 29.6% to 9.7% — exactly the part the flying card used to paint outside the stage.

Rotation was the other repeat offender. Dial 03 turns its knob with rotate, which also turned the speaker icon sitting inside it, so the icon stood on its head as the value climbed. A second animation on the icon alone, running the same angles in reverse, keeps it upright. The gauge arc had a related bug: give stroke-dasharray only one length and it reuses that number as the gap too, so the pattern repeats once the circle is longer than that length, and the arc tangled into itself. Drawing 270 degrees needs both numbers — 197.92 drawn and 66.06 skipped. Every one of those fixes is in the nine sources inside the zip, which opens with the archive password 49t2bve3, and the vanilla and React builds share the same numbers.

Accessibility (reduced-motion)

None of the nine makes a gesture the only way in. 01 has plus and minus buttons, 02 takes ArrowRight, 03 takes all four arrows plus Home and End, 04 has tab buttons, left/right arrows, Home and End, 05 has pass and skip buttons and left/right arrows, 06 has a back button, 07 takes up and down arrows, 08 has step buttons and Escape, and 09 has zoom buttons and arrow panning. The value-bearing dial carries role="slider" with aria-valuenow so its current number is announced, and the readouts in 01, 02, 03, 05, and 09 are aria-live="polite" so a change is spoken rather than only shown. Under prefers-reduced-motion: reduce only the autoplay loop stops; the real interaction and the state values stay. The single-handler approach to mouse, touch, and pen is documented in the MDN pointer events guide.

More thumb-sized parts live under the navigation category and the scroll category.

FAQ

Can I use pointer events alone instead of touch events?

Yes. None of the nine registers touchstart or mousedown; they all use one set of pointerdown, pointermove, and pointerup. Pointer events deliver mouse, finger, and pen in the same shape, so the same function runs whether you drag with a mouse or push with a thumb. The one thing to add for multi-touch, as in 09, is storing coordinates keyed by e.pointerId.

Why does my drag stop when the finger leaves the element?

Because the pointer was never captured. By default, an element stops receiving events the instant the finger crosses its boundary. Calling setPointerCapture(e.pointerId) once inside pointerdown, the way 02 does, routes every later event to that element until release, and a fast drag no longer strands the handle mid-track.

My swipe keeps fighting the page scroll

Decide which axis the browser keeps by using CSS touch-action. Horizontal-only 04 sets touch-action: pan-y, leaving vertical scrolling to the browser and claiming only the horizontal axis, while 07, 08, and 09 handle every direction themselves and set touch-action: none to turn native scrolling off entirely. Without that line, one finger drives two things at once.

Enter the archive password

The password is inside this article. You will find it as you read.