Touch Gestures in Vue Apps: Implementing Swipe, Drag, and Pinch
AI generated
<v/>
{ }
Vue.js · Touch Gestures · Mobile UX · Pointer Events
Implementing touch gestures in Vue apps correctly
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.

18 min read Vue 3 · Pointer Events · VueUse Swipe · Drag · Pinch to Zoom

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.

11. FAQ: Touch Gestures in Vue Apps

1What are pointer events?
A unified event model for mouse, touch, and stylus instead of separate handlers per input type.
2Distinguishing swipe from scroll?
Through touch-action: pan-y plus an angle check on the movement direction.
3Why doesn't preventDefault() work?
The listener is passive by default, must be explicitly registered with { passive: false }.
4What does setPointerCapture do?
Binds follow up events to the target element even if the finger moves beyond it during the movement.
5Detecting a swipe reliably?
Combination of minimum distance and minimum velocity instead of pure distance checking.
6Keeping 60fps during touch?
CSS transform instead of top/left, batch pointermove events with requestAnimationFrame.
7Implementing pinch to zoom?
Track two pointers by pointerId, calculate distance, derive zoom level from the ratio.
8Accessible without a keyboard alternative?
No, every gesture based action needs a focusable, keyboard operable alternative.
9VueUse or a custom solution?
VueUse for standard cases, custom pointer events logic for very specific requirements.
10Too many listeners on long lists?
Use virtualization so only visible elements register pointer listeners.