Touch events in JavaScript: taps, swipes and pinches

Touch events tell you where every finger is. Pointer events tell you the same thing with one code path for touch, mouse and pen, which is usually what you want.

JavaScript has two sets of events for fingers on a screen. Touch events (touchstart, touchmove, touchend, touchcancel) fire only for touch, and each event carries a list of every finger.

Pointer events (pointerdown, pointermove, pointerup, pointercancel) fire once per finger, and also for a mouse and a pen.

Try both side by side. Touch the pad with one finger, then two, or click it with a mouse.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Touch and pointer event log</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .pad {
    height: 130px; border-radius: 12px; display: grid; place-items: center;
    background: #fff; border: 2px dashed #b8c0cc; font-size: 15px; color: #4b5563;
    touch-action: none;  /* keep the browser from scrolling or zooming here */
    user-select: none;
  }
  .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }
  h3 { margin: 0 0 4px; font-size: 13px; }
  .t h3 { color: #9a3412; } .p h3 { color: #0f5132; }
  ol {
    margin: 0; padding: 6px 8px; list-style: none; height: 220px; overflow: auto;
    background: #fff; border-radius: 8px; font: 12px/1.5 ui-monospace, Consolas, monospace;
  }
  li { padding: 2px 0; border-bottom: 1px solid #eef0f3; overflow-wrap: anywhere; }
  button { margin-top: 10px; font: inherit; padding: 6px 14px; border-radius: 8px; border: 1px solid #c9ced6; background: #fff; }
</style>
</head>
<body>
<div class="pad" id="pad">Touch here with one or two fingers, or click</div>
<div class="cols">
  <div class="t"><h3>Touch events</h3><ol id="tlog"></ol></div>
  <div class="p"><h3>Pointer events</h3><ol id="plog"></ol></div>
</div>
<button id="clear" type="button">Clear</button>

<script>
  const pad = document.getElementById('pad');
  const tlog = document.getElementById('tlog');
  const plog = document.getElementById('plog');

  // Add a line; repeated move events update the last line instead of flooding the list
  function log(list, type, text) {
    const last = list.lastElementChild;
    if (type.endsWith('move') && last && last.dataset.type === type) {
      last.textContent = text;
      return;
    }
    const li = document.createElement('li');
    li.dataset.type = type;
    li.textContent = text;
    list.append(li);
    list.scrollTop = list.scrollHeight;
  }

  // Touch events: one event carries every finger, in three lists
  ['touchstart', 'touchmove', 'touchend', 'touchcancel'].forEach((type) => {
    pad.addEventListener(type, (e) => {
      const t = e.changedTouches[0];
      log(tlog, type, type + ' touches=' + e.touches.length +
        ' changed=' + e.changedTouches.length +
        ' ' + Math.round(t.clientX) + ',' + Math.round(t.clientY));
    });
  });

  // Pointer events: one event per pointer, the same for mouse, pen and touch
  ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'click'].forEach((type) => {
    pad.addEventListener(type, (e) => {
      if (type === 'pointermove' && e.pointerType === 'mouse' && e.buttons === 0) return;  // skip mouse hovering
      log(plog, type, type + ' ' + (e.pointerType || '') + ' id=' + (e.pointerId ?? '') +
        ' ' + Math.round(e.clientX) + ',' + Math.round(e.clientY));
    });
  });

  document.getElementById('clear').addEventListener('click', () => {
    tlog.textContent = '';
    plog.textContent = '';
  });
</script>
</body>
</html>
Touch events on the left, pointer events on the right. A mouse click only shows up on the right.

On a phone, one finger produces both columns. A mouse click fills only the pointer column, because touch events never fire for a mouse. Two fingers give touches=2 on the left and two different pointer ids on the right.

Touch events: touches, targetTouches and changedTouches

A touch event is one object for all fingers. Each finger is a Touch with its own identifier, clientX and clientY, and the event holds three lists of them.

List What it holds Use it for
e.touches Every finger on the screen right now Counting fingers
e.targetTouches Fingers that started on this element Ignoring fingers elsewhere
e.changedTouches Fingers that caused this event The finger that just landed, moved or lifted

The common mistake is reading e.touches[0] in touchend. The lifted finger is no longer on the screen, so touches can be empty. Read e.changedTouches[0] instead:

el.addEventListener('touchstart', (e) => {
  const t = e.changedTouches[0];
  console.log('down at', t.clientX, t.clientY, 'fingers:', e.touches.length);
});
el.addEventListener('touchend', (e) => {
  const t = e.changedTouches[0];  // e.touches no longer has this finger
  console.log('up at', t.clientX, t.clientY);
});

After a short tap, the browser also sends compatibility mouse events and a click. That is why plain click handlers work on phones without any touch code.

Touch vs pointer events: use one code path

Touch events need a second set of mouse listeners. Pointer events cover finger, mouse and pen.
Touch events need a second set of mouse listeners. Pointer events cover finger, mouse and pen.

With touch events, a swipe that should also work with a mouse needs two sets of listeners and two ways of reading coordinates. Pointer events remove that split. Every pointer gets its own pointerdown, pointermove and pointerup, with clientX and clientY on the event itself.

  • e.pointerType is 'mouse', 'pen' or 'touch', if you need to treat them differently.
  • e.pointerId stays the same for one finger from down to up, so two fingers never get mixed.
  • setPointerCapture(e.pointerId) keeps the moves coming to your element even when the finger slides off it.

The rest of this guide uses pointer events. The same pattern moves an element around in the draggable div guide, and addEventListener covers the listener options used below.

Detect a swipe: distance, speed and direction

A swipe is a press, a move and a release, judged at the release. Store where and when it started, then measure on pointerup.

A long drag passes on distance, a quick flick passes on speed, a slow nudge is ignored.
A long drag passes on distance, a quick flick passes on speed, a slow nudge is ignored.
let start = null;
card.addEventListener('pointerdown', (e) => {
  start = { x: e.clientX, y: e.clientY, t: e.timeStamp };
  card.setPointerCapture(e.pointerId);
});
card.addEventListener('pointerup', (e) => {
  const dx = e.clientX - start.x, dy = e.clientY - start.y;
  const dist = Math.max(Math.abs(dx), Math.abs(dy));
  const speed = dist / (e.timeStamp - start.t);   // px per ms
  if (dist >= 60 || (dist >= 20 && speed >= 0.5)) {
    const dir = Math.abs(dx) > Math.abs(dy)
      ? (dx > 0 ? 'right' : 'left')
      : (dy > 0 ? 'down' : 'up');
    console.log('swipe', dir);
  }
});

The numbers 60, 20 and 0.5 are starting points, not rules. Try the detector with different minimum distances, and switch touch-action to see what the browser does with each value:

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Swipe detector</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .opts { display: flex; flex-wrap: wrap; gap: 8px 16px; font-size: 14px; align-items: center; }
  select, input { font: inherit; }
  .stage { height: 200px; display: grid; place-items: center; margin: 12px 0; }
  .card {
    width: 200px; height: 150px; border-radius: 16px; display: grid; place-items: center;
    background: linear-gradient(135deg, #6366f1, #06b6d4); color: #fff;
    font-size: 26px; font-weight: 700; user-select: none; cursor: grab;
  }
  .card.back { transition: transform .2s; }
  .out { font: 13px/1.6 ui-monospace, Consolas, monospace; background: #fff; border-radius: 8px; padding: 8px 10px; }
  .out b { font-size: 15px; }
</style>
</head>
<body>
<div class="opts">
  <label>touch-action on card
    <select id="ta">
      <option>none</option>
      <option>pan-y</option>
      <option>auto</option>
    </select>
  </label>
  <label>Min distance <input id="min" type="range" min="20" max="150" value="60"> <span id="minv">60</span>px</label>
</div>
<div class="stage"><div class="card" id="card">Swipe me</div></div>
<div class="out" id="out">Swipe the card left, right, up or down.</div>

<script>
  const card = document.getElementById('card');
  const out = document.getElementById('out');
  const ta = document.getElementById('ta');
  const min = document.getElementById('min');
  const FAST = 0.5;       // px per ms: a quick flick counts even if it is short
  let start = null;       // { id, x, y, t } while a swipe is in progress

  card.style.touchAction = ta.value;
  ta.addEventListener('change', () => { card.style.touchAction = ta.value; });
  min.addEventListener('input', () => { document.getElementById('minv').textContent = min.value; });

  card.addEventListener('pointerdown', (e) => {
    if (start) return;                  // ignore a second finger
    start = { id: e.pointerId, x: e.clientX, y: e.clientY, t: e.timeStamp };
    card.setPointerCapture(e.pointerId);
    card.classList.remove('back');
  });

  card.addEventListener('pointermove', (e) => {
    if (!start || e.pointerId !== start.id) return;
    card.style.transform = `translate(${e.clientX - start.x}px, ${e.clientY - start.y}px)`;
  });

  card.addEventListener('pointerup', (e) => {
    if (!start || e.pointerId !== start.id) return;
    const dx = e.clientX - start.x, dy = e.clientY - start.y;
    const ms = Math.max(1, e.timeStamp - start.t);
    const dist = Math.max(Math.abs(dx), Math.abs(dy));
    const speed = dist / ms;
    let dir = 'none';
    if (dist >= +min.value || (dist >= 20 && speed >= FAST)) {
      dir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up');
    }
    out.innerHTML = `<b>Swipe: ${dir}</b><br>dx ${Math.round(dx)}px, dy ${Math.round(dy)}px, ` +
      `${Math.round(ms)} ms, ${speed.toFixed(2)} px/ms`;
    reset();
  });

  // The browser took the gesture (scroll or zoom): no pointerup will come
  card.addEventListener('pointercancel', (e) => {
    if (!start || e.pointerId !== start.id) return;
    out.innerHTML = '<b>pointercancel</b><br>The browser took this gesture. Try touch-action: none.';
    reset();
  });

  function reset() {
    start = null;
    card.classList.add('back');
    card.style.transform = '';
  }
</script>
</body>
</html>
Swipe the card in any direction. The readout shows dx, dy, time and speed. Switch touch-action to auto and swipe on a phone.

The speed check is what makes a short flick feel right. Without it, a quick 40 px flick would count as nothing, even though the user clearly meant to swipe.

touch-action: stop the browser from scrolling and zooming

A finger on a touch screen already means something to the browser: scroll, or pinch to zoom. When the browser decides the gesture is its own, it sends pointercancel and no more moves reach your code.

In the detector, set touch-action to auto and the readout says so.

Three useful values: auto leaves every gesture to the browser, pan-y leaves only vertical scrolling, none leaves nothing.
Three useful values: auto leaves every gesture to the browser, pan-y leaves only vertical scrolling, none leaves nothing.
Value The browser still handles Good for
auto Scrolling and zooming Normal content
pan-y Vertical scrolling only Sideways swipes in a scrolling page
pan-x Horizontal scrolling only Vertical swipes inside a sideways row
manipulation Scrolling and pinch zoom, not double-tap zoom Buttons tapped quickly in a row
none Nothing Drawing, pinch zoom, drag handles

Put it on the element you swipe, not on body. The browser reads touch-action when the finger first touches, so changing it in the middle of a gesture has no effect.

Passive listeners and preventDefault

The script way to stop scrolling is preventDefault() in a touchmove listener. It still works on an element listener. It does not work on a listener attached to window, document or body, because browsers make touchstart and touchmove listeners there passive by default.

A passive listener has promised not to cancel, so scrolling can start at once. Its preventDefault() call is ignored.

// ignored: document-level touchmove listeners are passive by default
document.addEventListener('touchmove', (e) => e.preventDefault());

// works: an element listener that says it is not passive
canvas.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });

Prefer touch-action in CSS. The browser knows the rule before any script runs, and scrolling elsewhere on the page stays fast.

Pinch to zoom with two pointers

Pointer events have no pinch event. Keep a Map of the pointers that are down. When there are two, compare the distance between them now with the distance when the second finger landed.

const pts = new Map();
let startDist = 0, startScale = 1, scale = 1;
const gap = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y); };

view.addEventListener('pointerdown', (e) => {
  view.setPointerCapture(e.pointerId);
  pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
  if (pts.size === 2) { startDist = gap(); startScale = scale; }
});
view.addEventListener('pointermove', (e) => {
  if (!pts.has(e.pointerId)) return;
  pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
  if (pts.size === 2) scale = startScale * gap() / startDist;
});
['pointerup', 'pointercancel'].forEach((t) =>
  view.addEventListener(t, (e) => pts.delete(e.pointerId)));

The zoomed element needs touch-action: none, otherwise the browser zooms the whole page instead. To zoom around the point between the fingers and pan a canvas, see canvas zoom and pan.

A finished example: swipe deck and pinch zoom

Both widgets use pointer events only, so a mouse works the same way as a finger. Each also has buttons, and the deck answers the arrow keys, so nobody needs a touch screen or a steady hand.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Swipe deck and pinch zoom</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .wrap { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; }
  h3 { margin: 0 0 8px; font-size: 14px; }
  .deck { position: relative; height: 190px; }
  .c {
    position: absolute; inset: 0 20px; border-radius: 16px; padding: 16px;
    color: #fff; font-size: 22px; font-weight: 700; user-select: none; cursor: grab;
    touch-action: pan-y;  /* vertical drags still scroll the page; horizontal ones are ours */
    box-shadow: 0 6px 18px rgba(0, 0, 0, .15);
  }
  .c.fly { transition: transform .3s, opacity .3s; }
  .row { display: flex; gap: 8px; align-items: center; margin-top: 10px; font-size: 14px; flex-wrap: wrap; }
  button { font: inherit; padding: 7px 14px; border-radius: 8px; border: 1px solid #c9ced6; background: #fff; color: #1d2330; }
  /* hover only where a real hover exists, so it does not stick after a tap */
  @media (hover: hover) { button:hover { background: #eef2ff; } }
  .view {
    height: 190px; border-radius: 12px; overflow: hidden; background: #fff;
    touch-action: none;  /* we handle pan and pinch ourselves */
  }
  .view svg { width: 100%; height: 100%; transform-origin: center; }
</style>
</head>
<body>
<div class="wrap">
  <section>
    <h3>Swipe right to like, left to skip</h3>
    <div class="deck" id="deck"></div>
    <div class="row">
      <button id="skip" type="button">&larr; Skip</button>
      <button id="like" type="button">Like &rarr;</button>
      <span id="score">Liked 0, skipped 0</span>
    </div>
  </section>
  <section>
    <h3>Pinch to zoom, drag to pan</h3>
    <div class="view" id="view">
      <svg id="pic" viewBox="0 0 200 120" aria-label="Mountain scene">
        <rect width="200" height="120" fill="#bfe3ff"/>
        <circle cx="160" cy="28" r="12" fill="#fcd34d"/>
        <path d="M0 120 L60 40 L100 90 L130 55 L200 120 Z" fill="#475569"/>
        <path d="M60 40 L72 56 L48 56 Z M130 55 L140 67 L120 67 Z" fill="#fff"/>
        <text x="100" y="112" font-size="6" text-anchor="middle" fill="#fff">tiny text: zoom in to read</text>
      </svg>
    </div>
    <div class="row">
      <button id="out" type="button">&minus;</button>
      <button id="in" type="button">+</button>
      <button id="fit" type="button">Reset</button>
      <span id="zoom">100%</span>
    </div>
  </section>
</div>

<script>
  /* Swipe deck: one pointer at a time, buttons and arrow keys do the same thing */
  const deck = document.getElementById('deck');
  const names = ['Lisbon', 'Kyoto', 'Oslo', 'Lima', 'Cairo'];
  const colors = ['#6366f1', '#0891b2', '#16a34a', '#ea580c', '#db2777'];
  let liked = 0, skipped = 0, drag = null;

  function deal() {
    deck.textContent = '';
    names.forEach((n, i) => {
      const c = document.createElement('div');
      c.className = 'c';
      c.textContent = n;
      c.style.background = colors[i];
      deck.prepend(c);                   // first name ends up on top
    });
  }
  const topCard = () => [...deck.children].filter((c) => !c.classList.contains('fly')).pop();

  function decide(like) {
    const c = topCard();
    if (!c) return;
    like ? liked++ : skipped++;
    document.getElementById('score').textContent = `Liked ${liked}, skipped ${skipped}`;
    c.classList.add('fly');
    c.style.transform = `translateX(${like ? 400 : -400}px) rotate(${like ? 20 : -20}deg)`;
    c.style.opacity = 0;
    setTimeout(() => { c.remove(); if (!topCard()) deal(); }, 300);
  }

  deck.addEventListener('pointerdown', (e) => {
    const c = topCard();
    if (drag || e.target !== c) return;  // only the top card, only one pointer
    drag = { id: e.pointerId, x: e.clientX, t: e.timeStamp, dx: 0 };
    c.setPointerCapture(e.pointerId);
  });
  deck.addEventListener('pointermove', (e) => {
    if (!drag || e.pointerId !== drag.id) return;
    drag.dx = e.clientX - drag.x;
    topCard().style.transform = `translateX(${drag.dx}px) rotate(${drag.dx / 20}deg)`;
  });
  function release(e) {
    if (!drag || e.pointerId !== drag.id) return;
    const speed = Math.abs(drag.dx) / Math.max(1, e.timeStamp - drag.t);
    const far = Math.abs(drag.dx) > deck.clientWidth * 0.3;
    if (e.type === 'pointerup' && (far || (speed > 0.5 && Math.abs(drag.dx) > 20))) decide(drag.dx > 0);
    else topCard().style.transform = '';     // not far or fast enough: snap back
    drag = null;
  }
  deck.addEventListener('pointerup', release);
  deck.addEventListener('pointercancel', release);

  document.getElementById('like').addEventListener('click', () => decide(true));
  document.getElementById('skip').addEventListener('click', () => decide(false));
  document.addEventListener('keydown', (e) => {
    if (e.key === 'ArrowRight') decide(true);
    if (e.key === 'ArrowLeft') decide(false);
  });
  deal();

  /* Pinch zoom: keep every pointer that is down, and zoom by the change in distance */
  const view = document.getElementById('view');
  const pic = document.getElementById('pic');
  const pts = new Map();                 // pointerId -> { x, y }
  let scale = 1, x = 0, y = 0, pinch = null;

  function draw() {
    pic.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
    document.getElementById('zoom').textContent = Math.round(scale * 100) + '%';
  }
  const clamp = (s) => Math.min(4, Math.max(1, s));
  const gap = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y); };

  view.addEventListener('pointerdown', (e) => {
    view.setPointerCapture(e.pointerId);
    pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
    if (pts.size === 2) pinch = { dist: gap(), scale };
  });
  view.addEventListener('pointermove', (e) => {
    const p = pts.get(e.pointerId);
    if (!p) return;
    if (pts.size === 1) { x += e.clientX - p.x; y += e.clientY - p.y; }  // one finger or mouse: pan
    p.x = e.clientX; p.y = e.clientY;
    if (pts.size === 2 && pinch) scale = clamp(pinch.scale * gap() / pinch.dist);
    draw();
  });
  function lift(e) {
    pts.delete(e.pointerId);
    pinch = null;                        // the next pinch starts fresh
  }
  view.addEventListener('pointerup', lift);
  view.addEventListener('pointercancel', lift);

  document.getElementById('in').addEventListener('click', () => { scale = clamp(scale * 1.25); draw(); });
  document.getElementById('out').addEventListener('click', () => { scale = clamp(scale / 1.25); draw(); });
  document.getElementById('fit').addEventListener('click', () => { scale = 1; x = 0; y = 0; draw(); });
</script>
</body>
</html>
Swipe a card right to like it or left to skip it, or use the buttons and arrow keys. Pinch or use + and - on the picture.
  • One finger per card: the deck stores the first pointerId and ignores any other pointer until it is released.
  • pan-y on the cards: a sideways drag moves the card, an up-and-down drag still scrolls the page.
  • Cancel means snap back: pointercancel puts the card back without counting it.
  • Hover only with a real hover: the button hover style sits inside @media (hover: hover).

Hover and the tap delay on touch screens

A finger cannot hover. On a touch screen, a tap can leave an element in the :hover state until the user taps somewhere else, so a hover colour looks stuck.

Wrap hover-only styles in a media query that checks for a real hover:

@media (hover: hover) {
  .btn:hover { background: #eef2ff; }
}
@media (hover: none) {
  .menu { display: block; }  /* show what hover would reveal */
}

Older mobile browsers waited a moment after each tap to see whether a second tap for double-tap zoom followed.

Current mobile browsers skip that wait on pages with a viewport meta tag set to width=device-width, so clicks already feel immediate and no tap library is needed.

When it does not work

What you see Cause Fix
The page scrolls instead of swiping The browser took the gesture and sent pointercancel touch-action: pan-y or none on the element
preventDefault() is ignored The listener is passive (default on window, document, body) Use touch-action, or { passive: false } on the element
Works on a phone, not with a mouse Only touch events are handled Switch to pointer events
A second finger makes the card jump The handler reads whichever pointer moved Store the first pointerId and ignore the rest
touchend has no coordinates Reading e.touches, which no longer has the finger Read e.changedTouches
A hover colour stays after a tap :hover applied by the tap Wrap it in @media (hover: hover)
Pinch zooms the whole page No touch-action on the zoom area touch-action: none on it

Touch code has to be tried on a real phone, and a screenshot cannot be swiped. An .html file sent to a phone may open as plain code, or not at all.

To test it on your own phone or send it to someone, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so people can swipe the cards and pinch the picture themselves. If you change the code later, the same link shows the new version.

Questions people ask

Should I use touch events or pointer events?

Pointer events, in most cases. They fire for a finger, a mouse and a pen, so one set of listeners covers every device. Touch events only fire for fingers, so mouse users need a second set of mouse listeners. Touch events are still useful when you want the whole list of fingers in one event object.

What is the difference between touches, targetTouches and changedTouches?

touches lists every finger on the screen. targetTouches lists the fingers that started on the element the listener is on. changedTouches lists the fingers that caused this event. On touchend the lifted finger is only in changedTouches, because it is no longer on the screen.

How do I detect a swipe in JavaScript?

Record the position and time on pointerdown. On pointerup, compute dx, dy and the elapsed time. Count it as a swipe if the distance passes a threshold, or if a shorter move was fast enough, and take the direction from the larger of dx and dy.

How do I make a swipe button in HTML?

Give the element touch-action: pan-y so sideways moves reach your code, and run the swipe check above on it. Keep an ordinary button next to it that does the same thing, so keyboard and mouse users can trigger the action without swiping.

Why does preventDefault not stop the page from scrolling?

The listener is probably passive. touchstart and touchmove listeners added to window, document or body are passive by default, and a passive listener cannot cancel scrolling. Use touch-action in CSS instead, or add the listener to the element itself with { passive: false }.

Keep reading