A swipe is a drag that gets classified into a single direction — left, right, up, or down — once it has traveled far enough to count. useSwipe watches pointer events on an element and reports that direction, turning a raw drag into the gesture that flips a carousel slide, dismisses a card, or opens a drawer. It builds on the unified Pointer Events API, so one code path covers mouse, touch, and pen.
Implement useSwipe(options). It returns a [bond, state] pair: spread bond on the element to start tracking on pointer down, and read state.direction and state.swiping. Two decisions turn a drag into a swipe — which axis dominates, and whether the drag passed a distance threshold.
function useSwipe(options) {
// options: { threshold = 50, onSwipe(direction, event) }
// returns [bond, state]
// bond: { onPointerDown } spread on the element
// state: { direction, swiping } direction is left|right|up|down|null
}
const [bond, state] = useSwipe({ onSwipe: (dir) => console.log(dir) });
// spread {...bond} on an element; drag right 80px and down 30px, then release
// logs 'right' — the bigger axis wins, so a diagonal is still one direction
const [bond, state] = useSwipe({ threshold: 80 });
// a 60px drag leaves state.direction as null — it didn't travel far enough
// a 90px drag to the right sets state.direction to 'right'
|dx| is greater than |dy| it is horizontal (right/left), otherwise vertical (down/up). One drag yields one direction, never two.threshold (default 50px). A shorter move is a tap: direction stays null and onSwipe does not fire.window — attach pointermove and pointerup to window, not the element, so a fast swipe that leaves the element still delivers its finish. Remove them on pointer up and on unmount.bond should not re-attach handlers every render. Keep its identity stable and read a fresh onSwipe from a ref.swiping — true from pointer down until release, so the element can be styled mid-gesture. You do not need velocity, multi-touch, or scroll-locking here.You will track a drag from pointer down to pointer up, then at release turn its total travel into a single direction — the bigger axis decides, and only if it cleared a threshold.
A swipe is a drag with an opinion. The browser gives you a stream of pointer positions; your job is to collapse that whole stream into one answer: did the user swipe left, right, up, down — or was that just a tap? Two decisions do all the work. First, a drag is never perfectly straight, so you have to choose which axis it mostly moved along. Second, a two-pixel twitch is not a swipe, so you have to require a minimum distance. Get those two right and everything else is event plumbing.
Snapshot the pointer position on pointer down, accumulate the travel as the pointer moves, and classify once on pointer up. Classification is the whole idea: compare how far the pointer went horizontally versus vertically, and let the bigger one win. If |dx| is greater than |dy| the swipe is horizontal — right when dx is positive, left when negative; otherwise it is vertical — down when dy is positive, up when negative. A diagonal that goes 80px right and 30px down is a right swipe, because 80 beats 30. It is not a right swipe and a down swipe at the same time.
The obvious version checks each axis by itself and reports whatever moved:
function onPointerUp() {
const { dx, dy } = deltaRef.current;
let direction = null;
if (dx !== 0) { direction = dx > 0 ? 'right' : 'left'; onSwipe?.(direction); }
if (dy !== 0) { direction = dy > 0 ? 'down' : 'up'; onSwipe?.(direction); }
setState({ direction, swiping: false });
}
For a clean horizontal drag this looks fine. But it has two bugs baked in. A diagonal has both a non-zero dx and a non-zero dy, so it fires onSwipe twice — a right swipe and a down swipe — and direction ends up whichever axis ran last. And with no minimum distance, a 3px jitter or an ordinary tap registers as a full swipe. You need to pick a single dominant axis and gate it behind a threshold.
const { useState, useRef, useCallback, useMemo, useEffect } = require('react');
function useSwipe(options = {}) {
// Keep the latest options (threshold + inline onSwipe) in a ref. The window
// listeners then never need rebuilding when the caller passes a fresh inline
// callback — that is what keeps the bond's identity stable across renders.
const optionsRef = useRef(options);
optionsRef.current = options;
const [state, setState] = useState({ direction: null, swiping: false });
const startRef = useRef(null); // pointer position at drag start; null when idle
const deltaRef = useRef({ dx: 0, dy: 0 }); // travel accumulated since start
const onPointerMove = useCallback((event) => {
if (!startRef.current) return;
// Track the running delta so pointer up can classify the whole gesture's
// travel (a touchend/pointerup does not always carry usable coordinates).
deltaRef.current = {
dx: event.clientX - startRef.current.x,
dy: event.clientY - startRef.current.y,
};
}, []);
const onPointerUp = useCallback(
(event) => {
if (!startRef.current) return;
startRef.current = null;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
const { dx, dy } = deltaRef.current;
const { threshold = 50, onSwipe } = optionsRef.current;
// Dominant axis FIRST: whichever delta is bigger decides horizontal vs
// vertical, so a diagonal is one swipe, not two. Then require that delta
// to clear the threshold — under it, this was a tap and direction stays null.
let direction = null;
if (Math.abs(dx) > Math.abs(dy)) {
if (Math.abs(dx) > threshold) direction = dx > 0 ? 'right' : 'left';
} else {
if (Math.abs(dy) > threshold) direction = dy > 0 ? 'down' : 'up';
}
setState({ direction, swiping: false });
if (direction && onSwipe) onSwipe(direction, event);
},
[onPointerMove],
);
const onPointerDown = useCallback(
(event) => {
startRef.current = { x: event.clientX, y: event.clientY };
deltaRef.current = { dx: 0, dy: 0 };
setState({ direction: null, swiping: true });
// Listen on WINDOW, not the element: a fast swipe can leave the element
// before the pointer lifts, and element listeners would miss the finish.
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
},
[onPointerMove, onPointerUp],
);
// Safety net: drop listeners if the component unmounts mid-swipe.
useEffect(
() => () => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
},
[onPointerMove, onPointerUp],
);
const bond = useMemo(() => ({ onPointerDown }), [onPointerDown]);
return [bond, state];
}
module.exports = { useSwipe };
Three shifts from the naive version. The classification picks the dominant axis with a single Math.abs(dx) > Math.abs(dy) comparison, so a diagonal produces exactly one direction. Each branch then guards on threshold, so a short drag leaves direction as null. And the move/up listeners live on window and are torn down on release and on unmount, so a fast swipe that outruns the element still lands its finish.
Without a minimum distance, direction would fire on the smallest movement — every tap, every accidental brush, every one-pixel wobble becomes a "swipe." The threshold is what separates intent from noise. A 10px drag under a 50px threshold reports nothing; a 60px drag reports its direction. The comparison is on the dominant delta only, so a swipe that goes far sideways but barely up still needs its sideways travel to clear the bar.
The move and up listeners go on window, added on pointer down and removed on pointer up. If they were on the element, a fast swipe would break: the pointer can travel faster than the browser repaints, so the cursor slips off the element before you lift your finger. An element-bound pointerup would then never fire, and the swipe would be stuck mid-gesture — swiping frozen at true, no direction ever reported. Listening on window guarantees you see the finish wherever it happens. It is the same reason a drag hook listens on window.
Spread bond on a card, threshold at its default of 50, with onSwipe logging the direction. The user flicks it up and to the right:
startRef becomes { x: 200, y: 300 }, deltaRef resets to { dx: 0, dy: 0 }, swiping flips to true, and the two window listeners attach.deltaRef becomes { dx: 10, dy: -60 }. Still tracking; nothing is classified yet.deltaRef: |dy| (60) is greater than |dx| (10), so the axis is vertical. dy is -60, which clears the 50px threshold, so direction is up. State becomes { direction: 'up', swiping: false }, the listeners detach, and onSwipe('up', event) fires once.Had the user only nudged the card 10px, |dy| would be under 50, direction would stay null, and onSwipe would never fire — the card would read that as a tap.
dx and dy independently — a diagonal fires two directions and the last one wins. Compare |dx| against |dy| and take the bigger axis, then sign it.threshold before setting a direction.pointerup is lost, so the gesture never ends. Attach pointermove/pointerup to window.onSwipe, spreading bond re-attaches listeners constantly. Read onSwipe from a ref and memoize bond once.window listener after unmount is a leak. Remove both on pointer up and in an effect cleanup.velocity and per-axis vxvy for exactly this.touch-action CSS — setting touch-action: none (or pan-y) on the element stops the browser from scrolling the page while a swipe is in progress, which otherwise cancels the gesture on touch devices.direction on every move instead (with an onSwiping callback) drives live UI like a card that follows the finger before it snaps.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A swipe is a drag that gets classified into a single direction — left, right, up, or down — once it has traveled far enough to count. useSwipe watches pointer events on an element and reports that direction, turning a raw drag into the gesture that flips a carousel slide, dismisses a card, or opens a drawer. It builds on the unified Pointer Events API, so one code path covers mouse, touch, and pen.
Implement useSwipe(options). It returns a [bond, state] pair: spread bond on the element to start tracking on pointer down, and read state.direction and state.swiping. Two decisions turn a drag into a swipe — which axis dominates, and whether the drag passed a distance threshold.
function useSwipe(options) {
// options: { threshold = 50, onSwipe(direction, event) }
// returns [bond, state]
// bond: { onPointerDown } spread on the element
// state: { direction, swiping } direction is left|right|up|down|null
}
const [bond, state] = useSwipe({ onSwipe: (dir) => console.log(dir) });
// spread {...bond} on an element; drag right 80px and down 30px, then release
// logs 'right' — the bigger axis wins, so a diagonal is still one direction
const [bond, state] = useSwipe({ threshold: 80 });
// a 60px drag leaves state.direction as null — it didn't travel far enough
// a 90px drag to the right sets state.direction to 'right'
|dx| is greater than |dy| it is horizontal (right/left), otherwise vertical (down/up). One drag yields one direction, never two.threshold (default 50px). A shorter move is a tap: direction stays null and onSwipe does not fire.window — attach pointermove and pointerup to window, not the element, so a fast swipe that leaves the element still delivers its finish. Remove them on pointer up and on unmount.bond should not re-attach handlers every render. Keep its identity stable and read a fresh onSwipe from a ref.swiping — true from pointer down until release, so the element can be styled mid-gesture. You do not need velocity, multi-touch, or scroll-locking here.You will track a drag from pointer down to pointer up, then at release turn its total travel into a single direction — the bigger axis decides, and only if it cleared a threshold.
A swipe is a drag with an opinion. The browser gives you a stream of pointer positions; your job is to collapse that whole stream into one answer: did the user swipe left, right, up, down — or was that just a tap? Two decisions do all the work. First, a drag is never perfectly straight, so you have to choose which axis it mostly moved along. Second, a two-pixel twitch is not a swipe, so you have to require a minimum distance. Get those two right and everything else is event plumbing.
Snapshot the pointer position on pointer down, accumulate the travel as the pointer moves, and classify once on pointer up. Classification is the whole idea: compare how far the pointer went horizontally versus vertically, and let the bigger one win. If |dx| is greater than |dy| the swipe is horizontal — right when dx is positive, left when negative; otherwise it is vertical — down when dy is positive, up when negative. A diagonal that goes 80px right and 30px down is a right swipe, because 80 beats 30. It is not a right swipe and a down swipe at the same time.
The obvious version checks each axis by itself and reports whatever moved:
function onPointerUp() {
const { dx, dy } = deltaRef.current;
let direction = null;
if (dx !== 0) { direction = dx > 0 ? 'right' : 'left'; onSwipe?.(direction); }
if (dy !== 0) { direction = dy > 0 ? 'down' : 'up'; onSwipe?.(direction); }
setState({ direction, swiping: false });
}
For a clean horizontal drag this looks fine. But it has two bugs baked in. A diagonal has both a non-zero dx and a non-zero dy, so it fires onSwipe twice — a right swipe and a down swipe — and direction ends up whichever axis ran last. And with no minimum distance, a 3px jitter or an ordinary tap registers as a full swipe. You need to pick a single dominant axis and gate it behind a threshold.
const { useState, useRef, useCallback, useMemo, useEffect } = require('react');
function useSwipe(options = {}) {
// Keep the latest options (threshold + inline onSwipe) in a ref. The window
// listeners then never need rebuilding when the caller passes a fresh inline
// callback — that is what keeps the bond's identity stable across renders.
const optionsRef = useRef(options);
optionsRef.current = options;
const [state, setState] = useState({ direction: null, swiping: false });
const startRef = useRef(null); // pointer position at drag start; null when idle
const deltaRef = useRef({ dx: 0, dy: 0 }); // travel accumulated since start
const onPointerMove = useCallback((event) => {
if (!startRef.current) return;
// Track the running delta so pointer up can classify the whole gesture's
// travel (a touchend/pointerup does not always carry usable coordinates).
deltaRef.current = {
dx: event.clientX - startRef.current.x,
dy: event.clientY - startRef.current.y,
};
}, []);
const onPointerUp = useCallback(
(event) => {
if (!startRef.current) return;
startRef.current = null;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
const { dx, dy } = deltaRef.current;
const { threshold = 50, onSwipe } = optionsRef.current;
// Dominant axis FIRST: whichever delta is bigger decides horizontal vs
// vertical, so a diagonal is one swipe, not two. Then require that delta
// to clear the threshold — under it, this was a tap and direction stays null.
let direction = null;
if (Math.abs(dx) > Math.abs(dy)) {
if (Math.abs(dx) > threshold) direction = dx > 0 ? 'right' : 'left';
} else {
if (Math.abs(dy) > threshold) direction = dy > 0 ? 'down' : 'up';
}
setState({ direction, swiping: false });
if (direction && onSwipe) onSwipe(direction, event);
},
[onPointerMove],
);
const onPointerDown = useCallback(
(event) => {
startRef.current = { x: event.clientX, y: event.clientY };
deltaRef.current = { dx: 0, dy: 0 };
setState({ direction: null, swiping: true });
// Listen on WINDOW, not the element: a fast swipe can leave the element
// before the pointer lifts, and element listeners would miss the finish.
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
},
[onPointerMove, onPointerUp],
);
// Safety net: drop listeners if the component unmounts mid-swipe.
useEffect(
() => () => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
},
[onPointerMove, onPointerUp],
);
const bond = useMemo(() => ({ onPointerDown }), [onPointerDown]);
return [bond, state];
}
module.exports = { useSwipe };
Three shifts from the naive version. The classification picks the dominant axis with a single Math.abs(dx) > Math.abs(dy) comparison, so a diagonal produces exactly one direction. Each branch then guards on threshold, so a short drag leaves direction as null. And the move/up listeners live on window and are torn down on release and on unmount, so a fast swipe that outruns the element still lands its finish.
Without a minimum distance, direction would fire on the smallest movement — every tap, every accidental brush, every one-pixel wobble becomes a "swipe." The threshold is what separates intent from noise. A 10px drag under a 50px threshold reports nothing; a 60px drag reports its direction. The comparison is on the dominant delta only, so a swipe that goes far sideways but barely up still needs its sideways travel to clear the bar.
The move and up listeners go on window, added on pointer down and removed on pointer up. If they were on the element, a fast swipe would break: the pointer can travel faster than the browser repaints, so the cursor slips off the element before you lift your finger. An element-bound pointerup would then never fire, and the swipe would be stuck mid-gesture — swiping frozen at true, no direction ever reported. Listening on window guarantees you see the finish wherever it happens. It is the same reason a drag hook listens on window.
Spread bond on a card, threshold at its default of 50, with onSwipe logging the direction. The user flicks it up and to the right:
startRef becomes { x: 200, y: 300 }, deltaRef resets to { dx: 0, dy: 0 }, swiping flips to true, and the two window listeners attach.deltaRef becomes { dx: 10, dy: -60 }. Still tracking; nothing is classified yet.deltaRef: |dy| (60) is greater than |dx| (10), so the axis is vertical. dy is -60, which clears the 50px threshold, so direction is up. State becomes { direction: 'up', swiping: false }, the listeners detach, and onSwipe('up', event) fires once.Had the user only nudged the card 10px, |dy| would be under 50, direction would stay null, and onSwipe would never fire — the card would read that as a tap.
dx and dy independently — a diagonal fires two directions and the last one wins. Compare |dx| against |dy| and take the bigger axis, then sign it.threshold before setting a direction.pointerup is lost, so the gesture never ends. Attach pointermove/pointerup to window.onSwipe, spreading bond re-attaches listeners constantly. Read onSwipe from a ref and memoize bond once.window listener after unmount is a leak. Remove both on pointer up and in an effect cleanup.velocity and per-axis vxvy for exactly this.touch-action CSS — setting touch-action: none (or pan-y) on the element stops the browser from scrolling the page while a swipe is in progress, which otherwise cancels the gesture on touch devices.direction on every move instead (with an onSwiping callback) drives live UI like a card that follows the finger before it snaps.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.