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 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.