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
- 01 Double-tap zoom
- 02 Slide-to-unlock track
- 03 Drag-to-turn value dial
- 04 Swipe tab switcher
- 05 Swipe card deck
- 06 Edge swipe back
- 07 Rubber-band boundary
- 08 Drag-up bottom sheet
- 09 Pinch zoom
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.