Swipe Card Stack

Drag the top card sideways and it tilts with your hand; throw it far enough and it flies off while the next card steps forward.

  • GSAP
  • Cards
  • Drag
  • Added
01Kuala Lumpur
Drag the top card left or right

AI prompt

Build a React swipeable card stack with GSAP. Render 4 cards absolutely positioned on top of each other; cards behind the top one are scaled down by 0.05 and moved down 10px per level. On pointerdown on the top card, capture the pointer and follow it: x = drag distance, rotation = x * 0.08 degrees. On release, if |x| > 110px, tween it off screen to x = ±400, rotation ±25, opacity 0 over 0.4s with "power2.in", then move it to the bottom of the stack and animate the remaining cards up one level with "power3.out". Otherwise spring it back to x 0, rotation 0 over 0.5s with "expo.out". Also support Left and Right arrow keys on the focused stack. Cards show Malaysian city names: Kuala Lumpur, Penang, Melaka, Ipoh. Under prefers-reduced-motion, skip the throw and just cycle cards with a 150ms fade.

Source

Demo.tsx
'use client'

import gsap from 'gsap'
import { useLayoutEffect, useRef, useState } from 'react'
import s from './demo.module.css'

const THROW = 110

export default function SwipeStack({ labels }: { labels: Record<string, string> }) {
  // the last element renders on top, so reverse to put the first city on top
  const [order, setOrder] = useState(() => labels.cards.split('|').reverse())
  const stack = useRef<HTMLDivElement>(null)
  const drag = useRef({ x0: 0, x: 0, active: false })

  // Lay the stack out after every reorder: each level behind the top shrinks and drops a little.
  useLayoutEffect(() => {
    const cards = Array.from(stack.current?.children ?? []) as HTMLElement[]
    cards.forEach((card, i) => {
      const level = cards.length - 1 - i
      gsap.to(card, { x: 0, rotation: 0, opacity: 1, scale: 1 - level * 0.05, y: level * 10, duration: 0.5, ease: 'power3.out' })
    })
  }, [order])

  const top = () => stack.current?.lastElementChild as HTMLElement | null

  const fling = (dir: 1 | -1) => {
    const card = top()
    if (!card) return
    const done = () => {
      // park the thrown card invisibly at the centre so it fades in at the back instead of flying back
      gsap.set(card, { x: 0, rotation: 0, opacity: 0 })
      setOrder((o) => [o[o.length - 1], ...o.slice(0, -1)])
    }
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      gsap.to(card, { opacity: 0, duration: 0.15, onComplete: done })
      return
    }
    gsap.to(card, { x: dir * 400, rotation: dir * 25, opacity: 0, duration: 0.4, ease: 'power2.in', onComplete: done })
  }

  const onDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (e.target !== top() && !top()?.contains(e.target as Node)) return
    e.currentTarget.setPointerCapture(e.pointerId)
    drag.current = { x0: e.clientX, x: 0, active: true }
  }
  const onMove = (e: React.PointerEvent) => {
    if (!drag.current.active) return
    drag.current.x = e.clientX - drag.current.x0
    gsap.set(top(), { x: drag.current.x, rotation: drag.current.x * 0.08 })
  }
  const onUp = () => {
    if (!drag.current.active) return
    drag.current.active = false
    const { x } = drag.current
    if (Math.abs(x) > THROW) fling(x > 0 ? 1 : -1)
    else gsap.to(top(), { x: 0, rotation: 0, duration: 0.5, ease: 'expo.out' })
  }

  return (
    <div className={s.wrap}>
      <div
        ref={stack}
        className={s.stack}
        tabIndex={0}
        role="group"
        aria-label={labels.hint}
        onPointerDown={onDown}
        onPointerMove={onMove}
        onPointerUp={onUp}
        onPointerCancel={onUp}
        onKeyDown={(e) => {
          if (e.key === 'ArrowLeft') fling(-1)
          if (e.key === 'ArrowRight') fling(1)
        }}
      >
        {order.map((city, i) => (
          <div key={city} className={s.card} aria-hidden={i !== order.length - 1}>
            <span className={s.n}>{String(labels.cards.split('|').indexOf(city) + 1).padStart(2, '0')}</span>
            <strong className={s.city}>{city}</strong>
          </div>
        ))}
      </div>
      <span className={s.hint}>{labels.hint}</span>
    </div>
  )
}
demo.module.css
.wrap { display: grid; justify-items: center; gap: 44px; }

.stack {
  position: relative;
  width: 170px;
  height: 130px;
  touch-action: none;
  border-radius: 16px;
}

.stack:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 6px; }

.card {
  position: absolute;
  inset: 0;
  display: grid;
  align-content: space-between;
  padding: 16px;
  border: 1px solid var(--stage-rule);
  border-radius: 16px;
  background: var(--stage-surface);
  color: var(--stage-ink);
  cursor: grab;
  user-select: none;
  transform-origin: 50% 100%;
}

.card:last-child { background: var(--stage-ink); color: var(--stage-bg); border-color: var(--stage-ink); }
.card:last-child:active { cursor: grabbing; }

.n { font: 500 var(--text-xs) / 1 var(--font-mono); color: var(--stage-accent); }
.city { font: 600 var(--text-xl) / 1.15 var(--font-display); }

.hint { font-size: var(--text-xs); color: var(--stage-muted); }

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.