---
name: swipe-cards
description: Use when you want "Casual, mobile, interactive" - Lets users swipe cards left/right like a deck.
---

# Swipe Cards

> **Category:** Card / Layout  -  **Personality:** Casual, mobile, interactive
>
> **Best use:** Matching, galleries, onboarding
>
> **Ratings:** Professional 3/5 - Casual 5/5 - Exotic 3/5 - Visual impact 4/5 - Difficulty 4/5 - Performance cost 3/5

## What it is

A Tinder-style deck: the top card follows your pointer, tilts as you drag, and shows a "like / nope" cue. Release it past a distance **or** velocity threshold and it flings off-screen, revealing the next card; release it short of the threshold and it springs back. It is a playful, decisive way to triage a queue (matches, photos, onboarding choices) one item at a time, and it maps perfectly to touch. The demo is a travel-discovery app ("roam") where you swipe destinations right to save or left to skip, with on-screen buttons and an undo as the keyboard/accessible path.

## Dependencies / CDN

**None - vanilla JS** (Pointer Events + CSS transforms). The demo only loads two optional Google Fonts:

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=Spectral:ital,wght@0,500;0,600;1,500&display=swap" rel="stylesheet">
```

## HTML

A deck wrapper, one `<article>` per card (top card last in z-order is set by JS), plus action buttons:

```html
<div class="deck">
  <article class="card" style="--img:url('/img/coast.jpg')" aria-label="Wailea Coast, Maui">
    <div class="photo"></div><div class="scrim"></div>
    <span class="stamp like" aria-hidden="true">Go</span>
    <span class="stamp nope" aria-hidden="true">Skip</span>
    <div class="info"><h3>Wailea Coast</h3><p>Maui, Hawaii</p></div>
  </article>
  <!-- ...more cards... -->
  <div class="empty"><h3>You've seen them all</h3><button class="reset">Start over</button></div>
</div>
<div class="actions">
  <button class="act nope" aria-label="Skip">&#10005;</button>
  <button class="act like" aria-label="Save">&#9829;</button>
</div>
```

## CSS

Cards are absolutely stacked; only `transform`/`opacity`/`filter` are animated. The drag class kills the transition so the card tracks the finger 1:1, then it is restored for the spring-back/fling.

```css
.deck{position:relative; width:100%; aspect-ratio:5/7}
.card{
  position:absolute; inset:0; border-radius:24px; overflow:hidden;
  transform-origin:50% 100%;          /* pivot near the bottom = natural top-swing */
  touch-action:none;                  /* CRITICAL: stop the browser scrolling mid-drag */
  user-select:none; will-change:transform; backface-visibility:hidden;
  transition:transform .52s cubic-bezier(.2,.74,.28,1), opacity .34s, filter .4s;
}
.card.is-top{cursor:grab}
.card.is-grabbing{cursor:grabbing; transition:none}   /* follow the pointer with no lag */
.photo{position:absolute; inset:0; background:var(--img) center/cover}
.scrim{position:absolute; inset:0; background:linear-gradient(to top,rgba(7,4,14,.94),transparent 60%)}

/* the two cues live on the card and fade in as you drag */
.stamp{position:absolute; top:22px; opacity:0; pointer-events:none;
  font:700 23px/1 'Outfit',sans-serif; letter-spacing:.13em; text-transform:uppercase;
  padding:6px 16px; border:3px solid currentColor; border-radius:12px}
.stamp.like{left:18px; transform:rotate(-14deg); color:#2dd4a6}
.stamp.nope{right:18px; transform:rotate(14deg); color:#ff5c72}
```

Behind cards are pushed up + scaled so a sliver peeks above the top card (set inline by `layout()`):
`transform: translateY(-10%) scale(.94)` for depth 1, `translateY(-18%) scale(.88)` for depth 2.

## JavaScript

```js
(function(){
  var deck = document.querySelector('.deck');
  var all  = [].slice.call(deck.querySelectorAll('.card'));
  var btnLike = document.querySelector('.act.like'), btnNope = document.querySelector('.act.nope');
  var reduce  = matchMedia('(prefers-reduced-motion: reduce)').matches;
  var THRESH = 0.40, FLICK = 0.55;            // 40% of width, or 0.55 px/ms
  var stack = all.slice(), hist = [], drag = null;
  var W = function(){ return deck.getBoundingClientRect().width; };

  function stamp(c, dir, a){                  // a = 0..1
    c.querySelector('.like').style.opacity = dir > 0 ? a : 0;
    c.querySelector('.nope').style.opacity = dir < 0 ? a : 0;
  }
  function layout(){                          // stack the top 3, hide the rest
    stack.forEach(function(c, i){
      if(i > 2){ c.style.display = 'none'; return; }
      c.style.display = ''; c.style.zIndex = 10 - i;
      if(i === 0){ c.classList.add('is-top'); c.style.transform = ''; c.style.filter = ''; }
      else{ c.classList.remove('is-top');
        c.style.transform = 'translateY('+(i*-8-2)+'%) scale('+(1-i*0.06)+')';
        c.style.filter = 'brightness('+(1-i*0.1)+')'; }
    });
  }
  function dismiss(c, dir){                    // dir: 1 = like/right, -1 = nope/left
    if(stack.indexOf(c) < 0) return;
    stack.splice(stack.indexOf(c), 1); hist.push({c:c, dir:dir});
    c.classList.add('is-leaving'); c.style.zIndex = 20; stamp(c, dir, 1);
    c.style.transform = 'translate('+(dir*(W()*1.2+220))+'px,40px) rotate('+(dir*24)+'deg)';
    c.style.opacity = '0';
    layout();                                  // the next card animates up to the top
    setTimeout(function(){ if(stack.indexOf(c) < 0) c.style.display = 'none'; }, reduce ? 20 : 540);
  }

  function down(e){
    var t = stack[0]; if(!t || !t.contains(e.target)) return;
    drag = {c:t, id:e.pointerId, x:e.clientX, y:e.clientY, dx:0, t:Date.now()};
    t.classList.add('is-grabbing'); t.setPointerCapture(e.pointerId);
  }
  function move(e){
    if(!drag || e.pointerId !== drag.id) return;
    drag.dx = e.clientX - drag.x; var dy = e.clientY - drag.y;
    var rot = Math.max(-18, Math.min(18, drag.dx / W() * 26));
    drag.c.style.transform = 'translate('+drag.dx+'px,'+dy+'px) rotate('+rot+'deg)';
    stamp(drag.c, drag.dx >= 0 ? 1 : -1, Math.min(1, Math.abs(drag.dx) / (W()*THRESH)));
  }
  function up(e){
    if(!drag || e.pointerId !== drag.id) return;
    var c = drag.c, dx = drag.dx, v = dx / Math.max(Date.now() - drag.t, 1);
    c.classList.remove('is-grabbing'); drag = null;
    if(Math.abs(dx) > W()*THRESH || Math.abs(v) > FLICK) dismiss(c, dx >= 0 ? 1 : -1);
    else { stamp(c, 0, 0); c.style.transform = ''; }   // snap back
  }
  deck.addEventListener('pointerdown', down);
  deck.addEventListener('pointermove', move);
  deck.addEventListener('pointerup', up);
  deck.addEventListener('pointercancel', up);

  btnLike.addEventListener('click', function(){ if(stack[0]) dismiss(stack[0], 1); });
  btnNope.addEventListener('click', function(){ if(stack[0]) dismiss(stack[0], -1); });
  layout();
})();
```

The live demo adds an **undo** (`hist.pop()` -> `void c.offsetWidth` to commit the off-screen pose, then `stack.unshift(c)` so it animates back in) and **Left/Right arrow-key** swiping on the focusable app shell.

## How it works

- **Pointer Events unify mouse + touch + pen.** One `pointerdown/move/up` set handles every input; `setPointerCapture()` keeps events coming to the card even when the finger leaves it.
- **The card IS the transform.** `translate(dx,dy) rotate()` is written straight from the pointer delta. `transform-origin: 50% 100%` pivots near the bottom so the top of the card swings - the signature card feel.
- **Two ways to commit.** A swipe counts if it passes a *distance* threshold (40% of the deck width) **or** a *velocity* threshold (a fast flick), so a quick short flick still works. Otherwise the card springs home.
- **The transition is the animation.** During drag the card has `transition:none` (1:1 tracking); on release the transition is restored, so just setting the final transform (off-screen for a fling, or empty for a snap-back) produces a smooth, eased motion for free - no rAF loop.
- **`layout()` owns the stack:** it assigns z-index, the resting transform of the top card, and the scaled/offset poses of the two cards behind it. Re-running it after a dismiss makes the next card rise into place.

## Customization

- **Sensitivity:** lower `THRESH` (e.g. `0.25`) for hair-trigger swipes; raise `FLICK` to require a faster flick.
- **Feel:** tune the spring with the cubic-bezier and duration on `.card`'s transition; change `rotate` scale (`/W()*26`) for more or less tilt.
- **Depth:** show more cards behind by raising the `i > 2` cap and adjusting the `translateY/scale` step in `layout()`.
- **Cues:** swap the `.stamp` text/colour (LIKE/NOPE, Yes/No, a heart/cross). They are pure DOM, so style them freely.
- **Actions:** the same `dismiss(stack[0], +/-1)` powers the buttons, so on-screen controls and gestures stay perfectly in sync.

## Accessibility & performance

- **Always provide buttons.** Dragging is mouse/touch-only; the Skip/Save buttons (and the demo's arrow keys) are the keyboard and screen-reader path to the exact same `dismiss()`.
- **`touch-action: none`** on the card is mandatory - without it the browser claims the gesture for scrolling and your drag stutters or never fires on touch.
- Animate **only `transform`/`opacity`** (compositor-friendly); never animate layout properties. `will-change: transform` hints the GPU.
- **Reduced motion:** check `matchMedia('(prefers-reduced-motion: reduce)')` and shorten the dismiss delay (the demo uses 20ms), and disable decorative background drift. Gestures still work - they just don't ease.
- Give each card a meaningful `aria-label`; mark the cue stamps and decorative layers `aria-hidden="true"`.

## Gotchas

- **Restore the transition before flinging.** If you forget to remove the `is-grabbing` (`transition:none`) class, the fling/snap-back will jump instead of animate.
- **Re-entrancy:** guard `dismiss()` against a card already leaving, and ignore `pointermove/up` whose `pointerId` isn't the active drag - otherwise a second finger hijacks the card.
- **The hide-timeout can race undo.** Only `display:none` the leaving card *if it's still gone* (`stack.indexOf(c) < 0`); otherwise an undo that re-adds it gets hidden a moment later.
- **`translate` order matters:** put `translate()` before `rotate()` so the card moves along screen axes, not its own rotated axes.
- **Stacking context:** the deck wrapper should not be `overflow:hidden` if you want the card to fly past its edge - clip at an outer framed stage instead.
