CSS + JS Cards & galleries #pointer #drag #cards #controls

Swipe deck in CSS + JavaScript

Drag the top card left or right. Past a threshold it flies off and rejoins at the back, and the cards behind move up through real depth. JS only writes numbers; CSS does every motion.

  • 105 lines of CSS
  • 8 lines of HTML
  • 93 lines of JS
  • No dependencies
  • MIT licensed

How it works

  1. Every card stores its position in the stack in --p (0 = top). CSS turns that into real depth with translateZ(calc(var(--p) * -45px)) — perspective does the shrinking, no scale() needed.
  2. JS only ever writes numbers into custom properties (--dx, --rot...) while a pointerdown/pointermove drag is in progress; the CSS transform and transition do every bit of motion.
  3. While dragging, the card gets transition: none so it follows the finger 1:1; release it and the transition comes back, so it either springs home or flies off.
  4. A fling does not remove the card. JS flags it .is-back (opacity 0, no transition) to jump it behind the deck invisibly, then removes that class a frame later so it fades back in at the bottom — the illusion of an endless deck.
  5. Use setPointerCapture so the drag keeps receiving events even if the pointer leaves the card, and arrow keys call the exact same fling() function as a completed drag.

Key techniques

  • Pointer Events + setPointerCapture
  • stack position --p → translateZ
  • transition: none while dragging
  • class swap for the fly-off

Copy-paste code

HTML 8 lines

<div class="scene">
  <div class="swipe" tabindex="0">
    <i style="--hue:262"><b>LIKE</b><b>NOPE</b><strong>Cube</strong><small>six faces</small></i>
    <i style="--hue:320"><b>LIKE</b><b>NOPE</b><strong>Ring</strong><small>rotate, then translate</small></i>
    <i style="--hue:175"><b>LIKE</b><b>NOPE</b><strong>Flap</strong><small>hinged on an edge</small></i>
    <i style="--hue:28"><b>LIKE</b><b>NOPE</b><strong>Lens</strong><small>perspective: 800px</small></i>
  </div>
</div>

CSS 105 lines

.scene {
  perspective: 800px;
}

.swipe {
  position: relative;
  width: 130px;
  height: 150px;
  outline: none;
  touch-action: pan-y; /* horizontal drags are ours, vertical ones still scroll the page */
  transform-style: preserve-3d;
  transform: translateY(-14px); /* the stack trails off downward: nudge it up to look centred */
}

.swipe:focus-visible i.is-top {
  outline: 2px solid #2ee6d6;
  outline-offset: 3px;
}

/* --p is the position in the stack (0 = top), written by JS. Depth is REAL: cards further
   back sit further away on Z, and the perspective makes them smaller — no scale() needed.
   --dx / --dy / --rot / --tilt follow the pointer while dragging. */
.swipe i {
  position: absolute;
  inset: 0;
  display: grid;
  align-content: end;
  gap: 2px;
  padding: 12px;
  border: 1px solid hsl(var(--hue) 90% 78% / 0.7);
  border-radius: 14px;
  background:
    radial-gradient(circle at 75% 22%, rgb(255 255 255 / 0.5), transparent 32%),
    linear-gradient(160deg, hsl(var(--hue) 85% 64%), hsl(calc(var(--hue) + 40) 75% 34%));
  box-shadow: 0 14px 20px -14px #000;
  color: #fff;
  font-style: normal;
  opacity: calc(1 - var(--p) * 0.2);
  user-select: none;
  touch-action: pan-y;
  transform: translate3d(var(--dx, 0px), calc(var(--p) * 14px + var(--dy, 0px)), calc(var(--p) * -45px))
    rotateY(var(--tilt, 0deg)) rotateZ(var(--rot, 0deg));
  transition:
    transform 0.4s cubic-bezier(0.3, 1.3, 0.5, 1),
    opacity 0.3s;
}

.swipe i.is-top {
  cursor: grab;
}

/* while the finger is down the card must follow 1:1, so no transition */
.swipe i.is-dragging {
  cursor: grabbing;
  transition: none;
}

/* flying off: JS has set --dx far outside the deck, CSS does the flight */
.swipe i.is-leaving {
  opacity: 0;
  transition:
    transform 0.35s ease-in,
    opacity 0.35s ease-in;
}

/* re-entering at the back: jump there invisibly, then fade in */
.swipe i.is-back {
  opacity: 0;
  transition: none;
}

.swipe i strong {
  font-size: 17px;
  line-height: 1;
}

.swipe i small {
  font-size: 11px;
  opacity: 0.85;
}

/* LIKE / NOPE stamps, faded in by how far the card has been dragged */
.swipe i b {
  position: absolute;
  top: 12px;
  padding: 2px 6px;
  border: 2px solid currentcolor;
  border-radius: 6px;
  font-size: 11px;
  letter-spacing: 0.08em;
}

.swipe i b:nth-of-type(1) {
  left: 10px;
  color: #7dffb0;
  opacity: var(--like, 0);
  transform: rotate(-14deg);
}

.swipe i b:nth-of-type(2) {
  right: 10px;
  color: #ffd0d8;
  opacity: var(--nope, 0);
  transform: rotate(14deg);
}

JS 93 lines

const root = document.querySelector('.swipe');
let order = [...root.querySelectorAll('i')];
let drag = null;
let busy = false;
let timer = 0;
const THRESHOLD = 60;

function layout(cards) {
  cards.forEach((el, p) => {
    el.style.setProperty('--p', String(p));
    el.classList.toggle('is-top', p === 0);
  });
}

function pose(card, dx, dy) {
  card.style.setProperty('--dx', dx.toFixed(1) + 'px');
  card.style.setProperty('--dy', dy.toFixed(1) + 'px');
  card.style.setProperty('--rot', (dx * 0.08).toFixed(2) + 'deg');
  card.style.setProperty('--tilt', (dx * 0.12).toFixed(2) + 'deg');
  card.style.setProperty('--like', Math.min(1, Math.max(0, dx / THRESHOLD)).toFixed(2));
  card.style.setProperty('--nope', Math.min(1, Math.max(0, -dx / THRESHOLD)).toFixed(2));
}

function unpose(card) {
  ['--dx', '--dy', '--rot', '--tilt', '--like', '--nope'].forEach((p) => card.style.removeProperty(p));
}

function fling(dir, dy) {
  if (busy) return;
  busy = true;
  const card = order[0];
  const rest = order.slice(1);
  card.classList.remove('is-top');
  card.classList.add('is-leaving');
  pose(card, dir * 260, dy || 0);
  layout(rest); // the others move up right away
  timer = window.setTimeout(() => {
    // jump to the back invisibly (no transition), then let it fade in there
    card.classList.add('is-back');
    card.classList.remove('is-leaving');
    unpose(card);
    order = [...rest, card];
    layout(order);
    void card.offsetWidth;
    card.classList.remove('is-back');
    busy = false;
  }, 360);
}

function down(e) {
  if (busy || drag || e.button > 0) return;
  const card = e.target.closest('i');
  if (card !== order[0]) return;
  e.preventDefault();
  root.focus({ preventScroll: true });
  root.setPointerCapture(e.pointerId);
  // screen pixels → CSS pixels (in case the page scales the deck)
  const scale = root.getBoundingClientRect().width / root.offsetWidth || 1;
  drag = { id: e.pointerId, x: e.clientX, y: e.clientY, scale, dx: 0 };
  order[0].classList.add('is-dragging');
}

function move(e) {
  if (!drag || e.pointerId !== drag.id) return;
  drag.dx = (e.clientX - drag.x) / drag.scale;
  pose(order[0], drag.dx, ((e.clientY - drag.y) / drag.scale) * 0.4);
}

function up(e) {
  if (!drag || e.pointerId !== drag.id) return;
  const { dx, scale, y } = drag;
  drag = null;
  const card = order[0];
  card.classList.remove('is-dragging');
  if (e.type === 'pointerup' && Math.abs(dx) > THRESHOLD) {
    fling(Math.sign(dx), ((e.clientY - y) / scale) * 0.4);
  } else {
    unpose(card); // the transition is back on, so it springs home
  }
}

function key(e) {
  if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
  e.preventDefault();
  fling(e.key === 'ArrowLeft' ? -1 : 1);
}

layout(order);
root.addEventListener('pointerdown', down);
root.addEventListener('pointermove', move);
root.addEventListener('pointerup', up);
root.addEventListener('pointercancel', up);
root.addEventListener('keydown', key);

Sass source 102 lines

.d-swipe {
  position: relative;
  width: 130px;
  height: 150px;
  outline: none;
  touch-action: pan-y; // horizontal drags are ours, vertical ones still scroll the page
  transform-style: preserve-3d;
  // the stack trails off downward, so nudge the whole thing up to look centred
  transform: translateY(-14px);

  &:focus-visible i.is-top {
    outline: 2px solid var(--accent-2);
    outline-offset: 3px;
  }

  // --p is the position in the stack (0 = top), written by JS. Depth is REAL: cards further back
  // sit further away on Z, and the perspective makes them smaller. No scale() needed.
  // --dx / --dy / --rot / --tilt follow the pointer while dragging.
  i {
    position: absolute;
    inset: 0;
    display: grid;
    align-content: end;
    gap: 2px;
    padding: 12px;
    border: 1px solid hsl(var(--hue) 90% 78% / 0.7);
    border-radius: 14px;
    background:
      radial-gradient(circle at 75% 22%, rgb(255 255 255 / 0.5), transparent 32%),
      linear-gradient(160deg, hsl(var(--hue) 85% 64%), hsl(calc(var(--hue) + 40) 75% 34%));
    box-shadow: 0 14px 20px -14px #000;
    color: #fff;
    font-style: normal;
    opacity: calc(1 - var(--p) * 0.2);
    user-select: none;
    touch-action: pan-y;
    transform: translate3d(var(--dx, 0px), calc(var(--p) * 14px + var(--dy, 0px)), calc(var(--p) * -45px))
      rotateY(var(--tilt, 0deg)) rotateZ(var(--rot, 0deg));
    transition:
      transform 0.4s cubic-bezier(0.3, 1.3, 0.5, 1),
      opacity 0.3s;

    &.is-top {
      cursor: grab;
    }

    // while the finger is down the card must follow 1:1, so no transition
    &.is-dragging {
      cursor: grabbing;
      transition: none;
    }

    // flying off: JS has set --dx far outside, CSS does the flight
    &.is-leaving {
      opacity: 0;
      transition:
        transform 0.35s ease-in,
        opacity 0.35s ease-in;
    }

    // re-entering at the back: jump there invisibly, then fade in
    &.is-back {
      opacity: 0;
      transition: none;
    }

    strong {
      font-size: 17px;
      line-height: 1;
    }

    small {
      font-size: 11px;
      opacity: 0.85;
    }

    // LIKE / NOPE stamps, faded in by how far the card has been dragged
    b {
      position: absolute;
      top: 12px;
      padding: 2px 6px;
      border: 2px solid currentcolor;
      border-radius: 6px;
      font-size: 11px;
      letter-spacing: 0.08em;
    }

    b:nth-of-type(1) {
      left: 10px;
      color: #7dffb0;
      opacity: var(--like, 0);
      transform: rotate(-14deg);
    }

    b:nth-of-type(2) {
      right: 10px;
      color: #ffd0d8;
      opacity: var(--nope, 0);
      transform: rotate(14deg);
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.