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
No external state machines. No reducers. Just motion and useState.
You'll need Motion for React. If you're on the older framer-motion package, the API is identical—swap the import.
We'll also use clsx for conditional classes, though you can inline styles if you prefer.
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.
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.
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 manages the order. When a swipe fires, we shift the top card to the bottom of the array—so nothing is ever truly deleted.
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.
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:
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.
Not everyone swipes. Add arrow key support and buttons so the interaction is accessible.
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:
Or pass it directly via the style prop on your motion.div:
Drop <CardStack /> anywhere in your app:
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 direction — onSwipe("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.
npm install motion
npm install clsx
// 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: "..." },
]
// 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>
)
}
// 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>
)
}
// 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>
// 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>
style={{ touchAction: "pan-y" }}
// 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>
)
}
.card-stack {
touch-action: pan-y; /* allow vertical scroll, hand x-drag to Motion */
}