Nov 03, 2025
5 min read

Swipe Gestures in Svelte: The Touch Events Nobody Talks About

Implementing swipe-to-complete on mobile means wrestling with touch events, vertical scrolling, and CSS that fights you.

Mobile users expect to swipe. Email apps let you swipe to archive. Todo apps let you swipe to complete. It’s 2025, so obviously my todo app needs it too, right?

Turns out, implementing swipe gestures properly is harder than it looks. You’ve got to handle touch events, track positions, apply transforms, prevent scrolling interference, and reset everything cleanly. Here’s what I learned building swipe-to-complete for my todo.txt app.

The Basic Touch Event Flow

The foundation is three touch events: touchstart, touchmove, and touchend. When the user touches the screen, you record where they started. As they drag, you track how far they’ve moved. When they release, you decide whether to trigger an action.

let cardElement = $state(null);
let startX = $state(0);
let currentX = $state(0);
let isDragging = $state(false);
let swipeDirection = $state(null); // 'left' or 'right'

const SWIPE_THRESHOLD = 80;

function handleTouchStart(e) {
  startX = e.touches[0].clientX;
  isDragging = true;
  swipeDirection = null;
}

function handleTouchMove(e) {
  if (!isDragging) return;

  currentX = e.touches[0].clientX;
  const diff = currentX - startX;

  // Update swipe direction
  if (Math.abs(diff) > 10) {
    swipeDirection = diff > 0 ? 'right' : 'left';
  }

  // Apply transform
  if (cardElement) {
    cardElement.style.transform = `translateX(${diff}px)`;
    cardElement.style.transition = 'none';
  }
}

function handleTouchEnd() {
  if (!isDragging) return;

  const diff = currentX - startX;

  if (cardElement) {
    cardElement.style.transition = 'transform 0.3s ease';
    cardElement.style.transform = 'translateX(0)';
  }

  // Trigger action if threshold met
  if (Math.abs(diff) > SWIPE_THRESHOLD) {
    if (swipeDirection === 'right' && !todo.completed) {
      onComplete?.();
    } else if (swipeDirection === 'left') {
      onDelete?.();
    }
  }

  isDragging = false;
  startX = 0;
  currentX = 0;
  swipeDirection = null;
}

The key is the threshold. If the user drags more than 80 pixels, we trigger the action. Otherwise, we just snap back. This prevents accidental swipes while still being responsive.

The CSS That Makes It Work

Here’s the trick that took me way too long to figure out. By default, touch events on mobile will scroll the page. If you try to swipe horizontally, you’ll end up scrolling vertically too. It feels janky.

The fix is touch-action: pan-y:

.todo-card {
  touch-action: pan-y;
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}

This tells the browser: “Allow vertical scrolling (pan-y), but I’m handling horizontal touch events myself.” It’s the difference between a gesture that works and one that fights the browser.

The user-select properties prevent text selection when dragging. Without them, users will accidentally select text as they swipe, which highlights everything in blue and looks broken.

Distinguishing Taps from Swipes

When the user taps a card, we want to open the edit dialog. When they swipe, we want to complete or delete. How do you tell the difference?

function handleClick() {
  if (!isDragging && Math.abs(currentX - startX) < 5) {
    onEdit?.();
  }
}

We check if the total movement is less than 5 pixels. If they barely moved, it’s a tap. If they dragged, even a little, we don’t fire the click. This prevents the edit dialog from popping up mid-swipe.

The Visual Feedback Loop

Users need to see what’s happening. As they swipe, the card moves with their finger. During touchmove, we disable transitions so it follows smoothly:

cardElement.style.transform = `translateX(${diff}px)`;
cardElement.style.transition = 'none';

When they release, we re-enable the transition so it snaps back gracefully:

cardElement.style.transition = 'transform 0.3s ease';
cardElement.style.transform = 'translateX(0)';

This feels responsive. The card moves instantly with the touch, then smoothly returns to position. No lag, no jank.

Swipe Direction Matters

In my app, swipe right completes a todo. Swipe left deletes it. This matches common patterns (Gmail, iOS Mail, etc.), so users expect it.

if (Math.abs(diff) > SWIPE_THRESHOLD) {
  if (swipeDirection === 'right' && !todo.completed) {
    onComplete?.();
  } else if (swipeDirection === 'left') {
    onDelete?.();
  }
}

Notice the guard: !todo.completed. You can’t complete something that’s already done. But you can still delete it by swiping left.

State Cleanup Is Critical

Every time a touch sequence ends—whether they swiped or not—we reset state:

isDragging = false;
startX = 0;
currentX = 0;
swipeDirection = null;

If you don’t reset, the next swipe will have stale data. The card might jump to a weird position, or the threshold calculation will be wrong. Clean state on every touchend.

What I’d Do Differently

If I were building this again, I’d add visual hints during the swipe. Show a green background when swiping right (complete), red when swiping left (delete). Users would know what’s about to happen before they release.

I’d also add haptic feedback on iOS using the Vibration API. A tiny buzz when they cross the threshold would feel great.

Lessons Learned

Touch events are lower-level than you think. You’re managing state, transforms, and thresholds manually. Use touch-action: pan-y to prevent scroll interference, or you’ll fight the browser on every gesture. Always reset state on touchend, even if no action triggered. The next user interaction depends on it. Test on real devices. Chrome DevTools touch simulation is helpful, but not the same as actual fingers on glass.

Swipe gestures make mobile apps feel native. They’re worth the effort, but you’ve got to respect the platform and handle the details correctly.

References