swipe, drag, and pinch without jank and without scroll conflicts
Touch gestures in Vue apps rarely fail on the core idea, but on details such as scroll conflicts, missing passive event configuration, and janky animations under load. With pointer events, VueUse composables, and a clean separation between recognition and reaction, swipe to delete, drag and drop, and pinch to zoom can be built robustly and performantly.
Table of Contents
- 1. Why touch gestures are more than click handlers
- 2. Pointer events instead of separate touch and mouse events
- 3. Swipe detection: direction, distance, and velocity
- 4. Drag handling with composables instead of inline logic
- 5. Pinch to zoom with two simultaneous pointers
- 6. Resolving scroll conflicts: touch-action and passive listeners
- 7. Performance: keeping 60fps during touch interaction
- 8. Accessibility: keyboard alternatives for gestures
- 9. VueUse versus Hammer.js versus a custom implementation
- 10. Summary
- 11. FAQ
1. Why touch gestures are more than click handlers
Touch gestures in Vue apps differ fundamentally from simple click interactions, because they play out over time and pass through multiple states: the start of contact, movement with direction and velocity, and an ending with a decision whether the gesture counts as completed. A single @click handler only knows one moment, while a swipe gesture continuously delivers position data, from which an action is derived only at the end.
The second fundamental difference concerns the interaction with the browser's native scroll behavior. Anyone implementing touch gestures in Vue apps competes directly with the operating system for the same touch events. A horizontal swipe to delete pattern inside a vertically scrollable list must cleanly distinguish between the user wanting to scroll and the user wanting to swipe, otherwise either janky scroll behavior or an accidentally triggered gesture results, while the user actually only wanted to scroll.
Third, touch gestures in Vue apps require a separation between recognition and reaction. Recognition, meaning evaluating position, direction, and velocity over time, is independent of the concrete action a component should perform on a recognized gesture. Anyone who cleanly separates these two layers can build gesture recognition logic as a reusable composable and use it across many different components, each with its own reaction.
2. Pointer events instead of separate touch and mouse events
Modern browsers support the Pointer Events API, which unifies mouse, touch, and stylus input under a single event model. For touch gestures in Vue apps this means: instead of separate listeners for touchstart/touchmove/touchend and mousedown/mousemove/mouseup, a single set of pointerdown/pointermove/pointerup listeners works across every input device.
An important detail is setPointerCapture, which ensures that all further pointer events stay bound to the same element, even if the finger or cursor leaves the original element during the movement. Without this call, a drag interaction loses connection to the target element as soon as the user moves quickly across other DOM elements, leading to aborted or inconsistent gestures.
// composables/usePointerTracking.js
import { ref } from 'vue'
export function usePointerTracking(elementRef, onMove, onEnd) {
const isTracking = ref(false)
const startX = ref(0)
const startY = ref(0)
function handlePointerDown(event) {
isTracking.value = true
startX.value = event.clientX
startY.value = event.clientY
// Ensures this element keeps receiving events even if the pointer
// moves outside its bounds during a fast drag
elementRef.value.setPointerCapture(event.pointerId)
}
function handlePointerMove(event) {
if (!isTracking.value) return
const deltaX = event.clientX - startX.value
const deltaY = event.clientY - startY.value
onMove({ deltaX, deltaY, event })
}
function handlePointerUp(event) {
if (!isTracking.value) return
isTracking.value = false
onEnd({ deltaX: event.clientX - startX.value, deltaY: event.clientY - startY.value })
}
return { handlePointerDown, handlePointerMove, handlePointerUp }
}
For simpler use cases, it is usually unnecessary to build this composable entirely from scratch: VueUse already provides ready made, well tested building blocks with useEventListener and specialized composables such as usePointerSwipe, based on the same pointer events foundation. For very specific requirements, such as multi finger gestures, a custom implementation based on pointer events remains the more flexible choice.
3. Swipe detection: direction, distance, and velocity
Reliable swipe detection for touch gestures in Vue apps needs more than just the distance traveled. A swipe should only count as completed once both a minimum distance is exceeded and a minimum velocity is reached, otherwise even slow, unintentional movements get misinterpreted as a swipe. VueUse offers a ready made composable with usePointerSwipe that makes exactly these thresholds configurable.
For direction detection it is also important to clearly distinguish horizontal from vertical swipes by evaluating the angle of the movement. A movement that is primarily vertical should not accidentally be counted as a horizontal swipe, even if a small horizontal component is present. This distinction prevents normal vertical scrolling from incorrectly triggering a swipe to delete.
// components/SwipeToDelete.vue — script setup block
import { ref } from 'vue'
import { usePointerSwipe } from '@vueuse/core'
const cardRef = ref(null)
const offsetX = ref(0)
const props = defineProps({ item: { type: Object, required: true } })
const emit = defineEmits(['delete'])
const { isSwiping, direction } = usePointerSwipe(cardRef, {
threshold: 60, // minimum distance in pixels
onSwipe(event) {
// Live-follow the finger while swiping left
if (direction.value === 'left') {
offsetX.value = Math.min(0, event.movementX)
}
},
onSwipeEnd(event, dir) {
if (dir === 'left' && Math.abs(offsetX.value) > 100) {
emit('delete', props.item)
} else {
offsetX.value = 0 // snap back if the swipe was not far enough
}
},
})
The decisive point of this pattern: the visual offset follows the finger movement in real time while onSwipe fires, while the actual delete decision is only made in onSwipeEnd based on the final distance. This separation between continuous visual feedback and a final action is characteristic of well functioning touch gestures in Vue apps.
4. Drag handling with composables instead of inline logic
Drag interactions, such as moving a card inside a container or reordering list items, follow a pattern similar to swipe, but additionally require continuous position updates throughout the entire movement, not just at the end. VueUse provides useDraggable for this, wrapping position, drag state, and boundary constraints in a single composable.
A common mistake with touch gestures in Vue apps that include drag functionality: the position calculation happens directly in the event handler without requestAnimationFrame, causing fast movements to trigger more DOM updates than the browser can render. VueUse already wraps this optimization internally; with a custom implementation, it should be added manually.
// components/DraggableCard.vue — script setup block
import { ref } from 'vue'
import { useDraggable } from '@vueuse/core'
const cardRef = ref(null)
const containerRef = ref(null)
const { x, y, isDragging } = useDraggable(cardRef, {
initialValue: { x: 40, y: 40 },
containerElement: containerRef, // constrains movement to the container bounds
onEnd(position) {
persistPosition(position) // e.g. save layout to localStorage or an API
},
})
// template binds :style="{ left: x + 'px', top: y + 'px' }" to cardRef
For list reordering via drag, an additional visual placeholder logic is recommended: while dragging, the target position of the remaining elements already shifts visibly before the drag ends, so the user can see the future arrangement. This preview logic is independent of the actual drag composable and should be implemented separately in the component that renders the list.
5. Pinch to zoom with two simultaneous pointers
Pinch to zoom differs from swipe and drag in that two simultaneous contact points must be processed, whose distance to each other determines the zoom level. For touch gestures in Vue apps with zoom functionality, for example in image galleries or map components, both active pointers must be distinguished by their pointerId and their positions continuously compared.
The zoom calculation is based on the ratio of the current distance between two pointers to the distance at the start of the gesture. It is also important to calculate the zoom center, so that the image zooms around the point between the two fingers instead of the center of the entire element, which would feel unnatural to the user.
// composables/usePinchZoom.js
import { ref } from 'vue'
export function usePinchZoom(elementRef) {
const scale = ref(1)
const activePointers = new Map()
let startDistance = 0
let startScale = 1
function distance(p1, p2) {
return Math.hypot(p1.clientX - p2.clientX, p1.clientY - p2.clientY)
}
function handlePointerDown(event) {
activePointers.set(event.pointerId, event)
if (activePointers.size === 2) {
const [p1, p2] = [...activePointers.values()]
startDistance = distance(p1, p2)
startScale = scale.value
}
}
function handlePointerMove(event) {
if (!activePointers.has(event.pointerId)) return
activePointers.set(event.pointerId, event)
if (activePointers.size === 2) {
const [p1, p2] = [...activePointers.values()]
const currentDistance = distance(p1, p2)
scale.value = Math.min(4, Math.max(1, startScale * (currentDistance / startDistance)))
}
}
function handlePointerUp(event) {
activePointers.delete(event.pointerId)
}
return { scale, handlePointerDown, handlePointerMove, handlePointerUp }
}
This implementation deliberately limits the zoom factor between 1 and 4, which in practice is sufficient for most image viewer use cases and prevents users from shrinking an image to an unusable size or magnifying it endlessly. For touch gestures in Vue apps with map components, an existing mapping library that already has pinch to zoom built in is often preferable over maintaining a custom implementation.
6. Resolving scroll conflicts: touch-action and passive listeners
The most common practical problem with touch gestures in Vue apps is the conflict with the browser's native scroll behavior. The CSS property touch-action controls which native touch gestures an element forwards to the browser at all. With touch-action: pan-y on a horizontally swipeable element, vertical scrolling still stays native, while horizontal movement goes to the custom JavaScript logic, without the browser getting in the way.
A second, often overlooked detail concerns passive event listeners. By default, Vue registers touch event listeners as passive, meaning preventDefault() inside the handler has no effect and the browser has already started scrolling in parallel. For cases where native scrolling actually needs to be suppressed, for example during an active drag, the listener must be explicitly registered as non passive.
<!-- touch-action controls which native gestures reach the browser at all -->
<div
class="swipe-card"
style="touch-action: pan-y;"
@pointerdown="handlePointerDown"
>
<!-- pan-y: vertical scroll stays native, horizontal swipe is handled in JS -->
</div>
// Registering a non-passive listener when preventDefault() must actually work
onMounted(() => {
elementRef.value.addEventListener('touchmove', handleTouchMove, { passive: false })
})
function handleTouchMove(event) {
if (isDragging.value) {
event.preventDefault() // only works because passive: false was set above
}
}
These two mechanisms together resolve most scroll conflicts in touch gestures in Vue apps: touch-action fundamentally decides which axis belongs to the browser, while non passive listeners force, in exceptional cases, a JavaScript gesture to take precedence over native scrolling.
7. Performance: keeping 60fps during touch interaction
Touch interactions are particularly susceptible to visible jank, because users directly compare the movement of their own finger with the visual feedback, making delays immediately noticeable. For smooth touch gestures in Vue apps, position updates should go through CSS transforms instead of top/left properties, because transform is rendered GPU accelerated by the browser, without triggering a layout recalculation.
A second performance lever concerns the frequency of reactive updates. With very fast pointer movements, pointermove events can fire more often than the browser can render. Batching position updates inside requestAnimationFrame ensures Vue reactivity and DOM updates run synchronized to the screen refresh, instead of updating unnecessarily often on every single event.
// Throttling position updates to the browser's paint cycle
let rafId = null
function handlePointerMove(event) {
if (rafId) return // a frame is already scheduled, skip this event
rafId = requestAnimationFrame(() => {
offsetX.value = event.clientX - startX.value // GPU-accelerated via transform
rafId = null
})
}
For complex lists with many simultaneously visible swipe cards, virtualization is also recommended, so that only the actually visible cards register pointer listeners. Without this restriction, long lists accumulate unnecessarily many active event listeners that consume computation time even while the user is touching an entirely different card.
8. Accessibility: keyboard alternatives for gestures
An often neglected aspect of touch gestures in Vue apps is accessibility for users who do not use touch input, for example during keyboard navigation or with assistive technologies. Every action reachable via swipe, such as deleting a list item, should also be reachable through a regular, focusable button operable via keyboard.
The same principle applies to drag and drop reordering: a purely touch or mouse based drag implementation completely excludes keyboard users. Alternative arrow key controls or dedicated up/down buttons next to each list item ensure the same functionality stays usable without a pointing device. Libraries like VueDraggable partially support such keyboard alternatives already, but a dedicated check in the concrete project remains necessary.
9. VueUse versus Hammer.js versus a custom implementation
For touch gestures in Vue apps, three fundamental paths are generally open, differing in scope, bundle size, and control.
| Approach | Bundle size | Vue integration | Control |
|---|---|---|---|
| VueUse | Small, tree shakeable | Native, composables | Good for standard gestures |
| Hammer.js | Medium, no tree shaking | Requires manual wiring | Very extensive gesture library |
| Custom pointer events logic | Minimal, only used code | Fully Vue native | Maximal, but more maintenance effort |
For most projects, VueUse is the most pragmatic starting point, because the composables already cover edge cases such as passive listeners, pointer capture, and threshold configuration. Hammer.js pays off mainly when many different, complex gestures are needed simultaneously and the additional bundle size is acceptable. A custom implementation based on pointer events remains sensible for very specific requirements not exactly covered by any library, for example the pinch to zoom logic from section five.
Mironsoft
Vue development, mobile UX, and touch interaction design
Touch interactions that genuinely feel native?
We implement swipe, drag, and pinch gestures in Vue apps, resolve scroll conflicts cleanly, and ensure smooth 60fps interactions even on older mobile devices.
Gesture audit
Reviewing existing touch interactions for scroll conflicts and jank
Composable library
Building reusable swipe, drag, and pinch composables
Accessibility fallbacks
Adding keyboard alternatives for every gesture based action
10. Summary
Touch gestures in Vue apps need more care than simple click handlers, because they play out over time, compete with native scroll behavior, and deliver differently precise input across devices. Pointer events unify mouse, touch, and stylus input under one event model, while touch-action and non passive listeners specifically control which axis belongs to the browser and which belongs to custom JavaScript logic.
Swipe, drag, and pinch detection benefit from separating continuous visual feedback from the final action decision, while requestAnimationFrame batching and CSS transforms ensure smooth 60fps performance. VueUse already covers most standard cases, custom pointer events logic remains the more flexible choice for special cases such as pinch to zoom. Keyboard alternatives for every gesture based action round off an accessible implementation.
Touch gestures in Vue apps — the essentials at a glance
Pointer events
A unified event model for mouse, touch, and stylus, complemented by setPointerCapture.
Scroll conflicts
touch-action controls which axis scrolls natively, non passive listeners force preventDefault().
Performance
requestAnimationFrame and CSS transform keep touch interactions at 60fps.
Accessibility
Every gesture based action needs a focusable keyboard alternative.