DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Building Tinder-style swipe cards in vanilla JS: one number drives the whole gesture

A swipe card feels physical — you grab it, it tilts, it flings away, the next one rises up behind it. It looks like the kind of thing you'd reach for a gesture library to build. When I built it from scratch in vanilla JavaScript, the entire "physical card" illusion came down to one number: dx, how far you have dragged the top card sideways. Everything else is arithmetic on that.

The stack is absolutely-positioned cards

The deck is one positioned box, and every card is position:absolute; inset:0, so they all pile up in exactly the same spot. Only the top card listens to the pointer. The cards behind it get a small translateY and scale per depth so the deck reads as a deck, and anything more than a few down is hidden for performance.

function stackStyle(el, depth){
  el.style.zIndex    = 100 - depth;
  el.style.transform = `translateY(${depth*14}px) scale(${1 - depth*0.05})`;
  el.style.opacity   = depth > 3 ? 0 : 1;
}
Enter fullscreen mode Exit fullscreen mode

Track the drag with pointer capture

On pointerdown over the top card, I remember where the grab started and call setPointerCapture. That is the detail that makes it robust: every later move and the release get delivered to this element even after the pointer leaves the card — which it will, mid-fling. I drop an animate class during the drag so the card tracks the finger with zero easing, and each move computes dx, plus a running velocity for the flick.

deck.addEventListener("pointerdown", e => {
  const el = topCard(); if (!el || !el.contains(e.target)) return;
  el.classList.remove("animate");
  el.setPointerCapture(e.pointerId);
  drag = { x0:e.clientX, y0:e.clientY, id:e.pointerId, lastX:e.clientX, lastT:performance.now(), vx:0 };
});
Enter fullscreen mode Exit fullscreen mode

The transform that sells it: translate + rotate from dx

Here is the whole trick in three lines. Translate the card by the raw drag delta, and rotate it in proportion to how far it has moved sideways — a small factor, clamped so it never spins past ~18°. That tiny coupling of horizontal distance to tilt is the entire physical-card feel: drag right and it leans right, drag left and it leans left, exactly like a real card pinched at one corner.

function render(dx, dy){
  const el = topCard();
  const rot = clamp(dx * 0.05, -18, 18);     // tilt ∝ horizontal drag
  el.style.transform = `translate(${dx}px, ${dy}px) rotate(${rot}deg)`;
  setStamps(el, dx);
}
Enter fullscreen mode Exit fullscreen mode

The same dx also drives the LIKE / NOPE stamp opacity (dx / threshold, clamped 0–1), so the moment a stamp goes fully solid is the exact moment letting go will commit. The interface teaches its own rule.

Release: a threshold OR a velocity flick

On pointerup I put the animate class back so whatever happens next eases smoothly, then make one decision. Commit if the card was dragged past a distance threshold or flicked fast enough — a quick flick should count even if it barely moved, which is what makes the gesture feel snappy instead of laborious. Otherwise it springs back to dead-centre, and because the transition class is on, that's a one-liner.

const dir = dx !== 0 ? Math.sign(dx) : Math.sign(vx);
if (Math.abs(dx) > THRESHOLD || Math.abs(vx) > 0.6)
  commit(dir > 0 ? "like" : "nope");
else
  springBack(el);
Enter fullscreen mode Exit fullscreen mode

Fling, promote, and undo from a history stack

Committing pops the card out of the model, transitions it well off-screen with a bit of extra rotation, and pushes a { data, decision } record onto a history stack. Re-laying the remaining cards is what promotes the next one to the top — and since those cards carry the animate class, they glide up for free. Because every commit recorded its data, undo is just "pop and put it back": rebuild the card, start it off-screen on the side it left from, unshift it as the new top, and relay so it slides home.

function commit(decision){
  const card = cards.shift();
  history.push({ data: card.data, decision });     // for undo
  const dir = decision === "like" ? 1 : -1;
  card.el.style.transform =
    `translate(${dir*(deck.offsetWidth+220)}px,-40px) rotate(${dir*22}deg)`;
  card.el.style.opacity = 0;
  card.el.addEventListener("transitionend", () => card.el.remove(), { once:true });
  relayStack();                                    // next card becomes top
  announce(`${decision==="like"?"Liked":"Passed on"} ${card.data.name}.`);
}
Enter fullscreen mode Exit fullscreen mode

The part people skip: keyboard and a screen reader

A drag-only card is unusable by keyboard and hostile on desktop, so the Nope / Like buttons and the arrow keys route through the exact same commit() the gesture uses — which means the animation, the history push and the announcement come for free with no duplicated logic. And accessibility isn't an afterthought: the deck is a focusable role="group" with an aria-label spelling out the gesture, and each decision writes a sentence to an aria-live="polite" region so a screen-reader user hears "Liked Aria." exactly when a sighted user sees the card fly off.

Every swipe UI you've used — Tinder, a Reels "not interested", an Anki flash-card — is this same shape underneath: a stack, a top card that tracks the pointer, a translate + rotate from the drag, a threshold-plus-velocity decision, and a history stack so nothing is lost to a fat-thumb.

Grab the top card and fling it:
https://dev48v.infy.uk/design/day37-swipe-cards.html

Top comments (0)