Swipeable Card Stack

Build a gesture-driven card stack where photos can be swiped left or right—and cycle back to the bottom of the pile.

Apr 2026

What We're Building

  • A stack of photo cards with depth (scale + offset so you can feel the pile)
  • Drag on the top card, with rotation tied to horizontal movement
  • A swipe threshold—drag far enough and the card flies off
  • Cards re-enter at the back of the stack after being swiped

No external state machines. No reducers. Just motion and useState.

Setup

You'll need Motion for React. If you're on the older framer-motion package, the API is identical—swap the import.

npm install motion

We'll also use clsx for conditional classes, though you can inline styles if you prefer.

npm install clsx

The Data

Let's define a simple array of photo cards. Swap in your own images or wire this up to an API—the component doesn't care.

// lib/photos.ts
export interface Photo {
  id: string
  src: string
  alt: string
}

export const photos: Photo[] = [
  { id: "1", src: "/photos/mountain.jpg", alt: "..." },
  { id: "2", src: "/photos/forest.jpg", alt: "..." },
  { id: "3", src: "/photos/coast.jpg", alt: "..." },
  { id: "4", src: "/photos/desert.jpg", alt: "..." },
  { id: "5", src: "/photos/city.jpg", alt: alt: "..." },
]

The Card Component

Each card is a motion.div that only accepts drag when it's on top. The key mechanic: we use useMotionValue and useTransform to derive rotation from the x position. This is the part that makes the interaction feel physical rather than programmatic.

// components/SwipeCard.tsx
"use client"

import { motion, useMotionValue, useTransform, AnimatePresence } from "motion"
import Image from "next/image"
import { Photo } from "@/lib/photos"

interface SwipeCardProps {
  photo: Photo
  isTop: boolean
  onSwipe: (direction: "left" | "right") => void
  stackIndex: number // 0 = top
}

const SWIPE_THRESHOLD = 100 // px from center to trigger a swipe

export function SwipeCard({ photo, isTop, onSwipe, stackIndex }: SwipeCardProps) {
  const x = useMotionValue(0)

  // Rotate up to ±20° as the card travels ±200px
  const rotate = useTransform(x, [-200, 0, 200], [-20, 0, 20])

  // Subtle opacity on the left/right edges so you get a sense of commitment
  const opacity = useTransform(x, [-200, -100, 0, 100, 200], [0.6, 1, 1, 1, 0.6])

  function handleDragEnd() {
    const xVal = x.get()
    if (xVal > SWIPE_THRESHOLD) {
      onSwipe("right")
    } else if (xVal < -SWIPE_THRESHOLD) {
      onSwipe("left")
    }
    // If under threshold, Motion's dragElastic/dragSnapToOrigin snaps it back
  }

  // Stack the cards visually: cards deeper in the stack are smaller + lower
  const scale = 1 - stackIndex * 0.05
  const yOffset = stackIndex * 12

  return (
    <motion.div
      className="absolute inset-0 cursor-grab active:cursor-grabbing"
      style={{
        x: isTop ? x : 0,
        rotate: isTop ? rotate : 0,
        opacity: isTop ? opacity : 1,
        scale,
        y: yOffset,
        zIndex: 10 - stackIndex,
      }}
      drag={isTop ? "x" : false}
      dragConstraints={{ left: 0, right: 0 }}
      dragElastic={0.85}
      dragSnapToOrigin
      onDragEnd={handleDragEnd}
      initial={{ scale: 0.9, opacity: 0 }}
      animate={{ scale, y: yOffset, opacity: 1 }}
      exit={{
        x: x.get() > 0 ? 600 : -600,
        opacity: 0,
        rotate: x.get() > 0 ? 30 : -30,
        transition: { duration: 0.35, ease: [0.32, 0, 0.67, 0] },
      }}
      transition={{ type: "spring", stiffness: 300, damping: 30 }}
    >
      <div className="relative w-full h-full rounded-2xl overflow-hidden shadow-2xl">
        <Image
          src={photo.src}
          alt={photo.alt}
          fill
          className="object-cover"
          draggable={false} // prevent browser's native image drag
          priority={stackIndex < 2}
        />
        {/* Subtle label at the bottom */}
        <div className="absolute bottom-0 inset-x-0 p-4 bg-gradient-to-t from-black/60 to-transparent">
          <p className="text-white text-sm font-medium">{photo.alt}</p>
        </div>
      </div>
    </motion.div>
  )
}

A few things worth calling out:

dragConstraints={{ left: 0, right: 0 }} + dragElastic={0.85} — Setting constraints to zero and elastic to a high value gives you that rubbery feel. The card lags behind your finger slightly, then snaps back if released early.

dragSnapToOrigin — When the user releases below the swipe threshold, the card snaps cleanly home. No manual spring animation needed.

exit on the parent AnimatePresence — The exit animation reads the current x value to decide which direction to throw the card. This is the moment it flies off screen.


The Stack Container

The stack manages the order. When a swipe fires, we shift the top card to the bottom of the array—so nothing is ever truly deleted.

// components/CardStack.tsx
"use client"

import { useState } from "react"
import { AnimatePresence } from "motion"
import { SwipeCard } from "./SwipeCard"
import { photos as initialPhotos, Photo } from "@/lib/photos"

export function CardStack() {
  const [stack, setStack] = useState<Photo[]>(initialPhotos)

  function handleSwipe(direction: "left" | "right") {
    console.log(`Swiped ${direction}: ${stack[0].alt}`)

    setStack(prev => {
      const [top, ...rest] = prev
      return [...rest, top] // move top card to the back
    })
  }

  // Only render the top 3 cards for performance
  const visible = stack.slice(0, 3)

  return (
    <div className="relative w-80 h-[480px] mx-auto select-none">
      <AnimatePresence initial={false}>
        {visible.map((photo, index) => (
          <SwipeCard
            key={photo.id}
            photo={photo}
            isTop={index === 0}
            stackIndex={index}
            onSwipe={handleSwipe}
          />
        ))}
      </AnimatePresence>

      {/* Swipe hint */}
      <p className="absolute -bottom-8 inset-x-0 text-center text-sm text-gray-400">
        Drag left or right
      </p>
    </div>
  )
}

Why slice(0, 3)? Rendering only the top 3 cards keeps the DOM lean. The user can never see deeper than 2–3 cards in the stack anyway, so there's no reason to mount all five (or fifty).

AnimatePresence initial={false} — The initial={false} prop prevents the enter animation from firing on first render. Without it, every card would animate in when the page loads.


Adding Swipe Direction Indicators

The interaction is already good. Let's make it great by showing a visual cue—a colored overlay that fades in as the user drags further.

Add this inside the SwipeCard component, just before the closing motion.div:

// Inside SwipeCard, add these two overlays inside the card div:

{/* "PASS" overlay — appears on left drag */}
<motion.div
  className="absolute inset-0 bg-red-500/40 rounded-2xl flex items-center justify-center"
  style={{
    opacity: useTransform(x, [-SWIPE_THRESHOLD, 0], [1, 0]),
  }}
>
  <span className="text-white font-bold text-3xl rotate-12 border-4 border-white rounded-lg px-3 py-1">
    PASS
  </span>
</motion.div>

{/* "LIKE" overlay — appears on right drag */}
<motion.div
  className="absolute inset-0 bg-emerald-500/40 rounded-2xl flex items-center justify-center"
  style={{
    opacity: useTransform(x, [0, SWIPE_THRESHOLD], [0, 1]),
  }}
>
  <span className="text-white font-bold text-3xl -rotate-12 border-4 border-white rounded-lg px-3 py-1">
    LIKE
  </span>
</motion.div>

Both overlays use useTransform against the same x motion value. One fades in as x goes negative, the other as x goes positive. They're invisible at rest and cost nothing when not dragging.


Keyboard & Button Controls

Not everyone swipes. Add arrow key support and buttons so the interaction is accessible.

// In CardStack.tsx, add keyboard handling:
import { useEffect } from "react"

useEffect(() => {
  function onKeyDown(e: KeyboardEvent) {
    if (e.key === "ArrowLeft")  handleSwipe("left")
    if (e.key === "ArrowRight") handleSwipe("right")
  }
  window.addEventListener("keydown", onKeyDown)
  return () => window.removeEventListener("keydown", onKeyDown)
}, [stack])

// And add buttons below the stack:
<div className="flex gap-4 justify-center mt-12">
  <button
    onClick={() => handleSwipe("left")}
    className="w-12 h-12 rounded-full border-2 border-red-400 text-red-400 flex items-center justify-center hover:bg-red-50 transition-colors"
    aria-label="Pass"
  >

  </button>
  <button
    onClick={() => handleSwipe("right")}
    className="w-12 h-12 rounded-full border-2 border-emerald-400 text-emerald-400 flex items-center justify-center hover:bg-emerald-50 transition-colors"
    aria-label="Like"
  >

  </button>
</div>

Touch on Mobile

Motion handles pointer events natively, so touch works without any extra code. One thing to watch: if your card stack is inside a scrollable container, vertical scroll and horizontal drag will compete. Fix it by disabling scroll during drag with a CSS touch action:

.card-stack {
  touch-action: pan-y; /* allow vertical scroll, hand x-drag to Motion */
}

Or pass it directly via the style prop on your motion.div:

style={{ touchAction: "pan-y" }}

Putting It Together

Drop <CardStack /> anywhere in your app:

// app/page.tsx
import { CardStack } from "@/components/CardStack"

export default function Page() {
  return (
    <main className="min-h-screen flex flex-col items-center justify-center bg-gray-50">
      <h1 className="text-2xl font-semibold mb-12 text-gray-800">Your Photos</h1>
      <CardStack />
    </main>
  )
}

What to Try Next

Undo — Keep a history array and pop from it to put a card back on top. Pair with a shake animation on the stack to signal the undo.

Callbacks per directiononSwipe("left") and onSwipe("right") are already wired up. Connect them to whatever matters: save to favorites, add to a queue, log to an API.

Velocity-based throw — Read dragInfo.velocity.x in onDragEnd instead of position. Fast flicks should trigger a swipe even if the drag distance is short. More realistic, more fun.

Portrait vs. landscape — Cards don't have to be square. A tall portrait ratio (like 3:4) looks great for people photos; landscape suits landscapes. Let the content drive the shape.


The complete component is about 120 lines split across two files. Motion does the heavy lifting—physics, exit animations, transform derivation—so the code you write is almost entirely about intent, not implementation. That's the deal.