JavaScript Touch Events Table
| Piece | What it does | Field note |
|---|---|---|
touchstart/move/end | The lifecycle | One finger = one Touch; each keeps a stable identifier from start to end - track by identifier, not index |
changedTouches | The event's cast | Fingers THIS event is about: start = arrivals, move = movers, end/cancel = leavers; touches = all current |
touchcancel | The takeover signal | Browser claims the gesture (scroll/zoom/system UI) - same cleanup branch as touchend or state leaks |
passive listeners | The scroll contract | Document-level touchstart/move are passive by default in Chrome: preventDefault is a silent no-op there |
touch-action CSS | The modern gate | Element-level gesture zoning: none/pan-x/pan-y/pinch-zoom - the declarative way to own a drag |
identifier tracking | The multi-touch law | touches is a list, not a map - store id to state; indexing touches[0] across events breaks pinch |
300ms delay | The history tax | Double-tap-zoom detection once delayed click; viewport meta plus touch-action killed it |
Pointer Events | The successor | pointerdown/move/up unify mouse/pen/touch with setPointerCapture - new code starts there |
Touch events are the browser's raw multi-finger feed: touchstart when fingers land, touchmove as they slide, touchend when they lift, touchcancel when the browser takes the gesture back. Each event carries three lists - touches (every finger currently down), targetTouches (fingers that started on THIS element), and changedTouches (the fingers this particular event is about) - and every finger keeps a stable identifier from touchstart to touchend, which is the only reliable way to track it.
Bottom line: the modern browser does not ask permission before claiming your gesture. Document-level touchstart and touchmove listeners are passive by default in Chrome, which means preventDefault() inside them is a silent no-op - the page scrolls anyway and your drag handler never knows why it lost. The two honest gates are explicit: addEventListener(type, fn, { passive: false }) when you truly need to block, and the touch-action CSS property for the declarative element-level version (pan-y lets vertical scroll through while your horizontal drag owns the rest).
The honest part: touch events are the legacy-but-alive API. Pointer Events unify mouse, pen and touch with one event family and setPointerCapture, and every new surface should start there; touch events remain mandatory reading because they carry detail pointer events abstract away (per-finger force and radius lists, the exact multi-finger bookkeeping), because mountains of production code still run on them, and because touchcancel - the event that means the BROWSER stole your gesture mid-drag - has no pointer-events equivalent you can safely ignore either.
How to use
- Track fingers by identifier, never by list index: on touchstart, record e.changedTouches[i].identifier into your state map; on touchmove, look fingers up by that identifier in e.changedTouches. touches is an arbitrary-ordered list, and indexing it across events is the single-finger habit that breaks the moment a second finger joins a drag.
- Read changedTouches as the event's cast list: touchstart means arrivals, touchmove means movers, touchend and touchcancel mean leavers. A pinch handler needs two identifiers from the start event, then distances computed per move - and it dies politely when either identifier leaves.
- Treat touchcancel as touchend that requires cleanup: the browser fires it when scroll, zoom, or system UI claims the gesture. If your drag state only resets on touchend, every stolen gesture leaves a stuck element - handle cancel with the same branch, and test it by dragging into a page scroll.
- Control scroll ownership deliberately: { passive: false } only on the specific element that must block scrolling (it de-scrolls the main thread - never on document-level move handlers), and touch-action: none / pan-x / pan-y on the element as the zero-JS declaration. The CSS route is the modern default; the listener flag is the surgical fallback.
- Start new work on pointer events: pointerdown/move/up cover mouse, pen and touch with one API, setPointerCapture keeps a dragging element owned even when the finger leaves its bounds, and the same cancel logic applies (pointercancel). Reach for touch events when you need the per-finger lists pointer events flatten away.
Frequently asked questions
What is the difference between touches, targetTouches and changedTouches?
touches: every finger currently on the screen. targetTouches: the subset that started on the element listening. changedTouches: the fingers THIS event is about - new fingers on touchstart, moved fingers on touchmove, lifted or stolen fingers on touchend/touchcancel. The classic bug: reading e.touches[0] on touchend to find out which finger left - it is not there anymore, touches only holds fingers still down; the leaving finger lives in changedTouches. In practice: state machines read changedTouches, gesture math reads touches when it needs the whole hand.
Why does preventDefault() not stop scrolling in my touchmove handler?
Because the listener is passive by default at the document/window level in Chrome (and on touchstart/touchmove broadly): passive is a promise that you will not call preventDefault, so the browser starts scrolling immediately without waiting for your handler - and your preventDefault call inside it does nothing, silently. The fixes, in preference order: touch-action CSS on the element (declarative, lets the browser optimize), then addEventListener with { passive: false } on that specific element only. The cost of passive:false is real - every touchmove on that element blocks the compositor - which is exactly why the default flipped in the first place.
When does touchcancel fire instead of touchend?
Whenever the browser claims the gesture away from your element: the user's drag turned into a page scroll or pinch zoom, an incoming phone call banner slides over, a system edge-swipe navigates, or the browser needs the gesture for accessibility handling. Your handler state machine must treat cancel as a first-class exit - same cleanup as touchend (clear the identifier map, release any captured state) - or every stolen drag leaks a ghost drag that keeps following later touches. Testing it: start a drag and let the page scroll - the handler that logs end will log cancel instead.
Should new code use touch events or pointer events?
Pointer events, with touch events as reading material. The pointer family unifies mouse, pen and touch (one handler shape, isPrimary for the first finger, pointerId instead of touch identifier), adds setPointerCapture so a dragging element keeps events even when the pointer leaves its bounds - something touch handlers re-implement by listening on window - and has the browser-implemented click/tap semantics. Touch events still matter for legacy code and for the details pointer events smooth over: per-finger touch lists (touches/targetTouches) and the touchcancel bookkeeping pattern that pointercancel inherits but many handlers forget. The honest migration note: multi-touch pinch on pointer events means tracking two pointerIds yourself - the same identifier discipline, different event names.