How to build a swipeable testimonials card stack in React
A swipeable testimonials card stack in React renders testimonials as absolutely-positioned cards stacked on top of each other. The front card responds to horizontal drag via Framer Motion's useMotionValue and rotates with useTransform; a drag offset above 100px triggers dismissal, revealing the card beneath.
- Stack: React 18 + Framer Motion 11 + lucide-react, ~170 lines, zero extra dependencies.
- Core Framer Motion API: useMotionValue, useTransform, AnimatePresence, drag with dragElastic.
- Only the front card is draggable; the two cards beneath are rendered at reduced opacity as depth cues.
- Accessible: card content uses semantic markup; a reset button restores dismissed cards when the stack empties.
- Works on touch screens, Framer Motion's drag fires on pointer events, not mouse events specifically.
Testimonials Stack Swipe brings a familiar mobile gesture, the Tinder swipe, to social proof sections. Instead of a static grid or a looping carousel, visitors actively dismiss cards left or right, which makes each testimonial feel like a deliberate discovery rather than background noise. The pattern works especially well on mobile-first products and anything targeting a younger, app-native audience.
Anatomy
The section has a centered header (title + subtitle) and a fixed-height card container (480px wide, 320px tall) that holds up to three cards at once via absolute positioning. Cards are rendered in reverse slice order so the top card appears last in the DOM and sits visually in front. A dismissal state is tracked in a Set; when the set equals the full testimonials array length, the empty-state view with a reset button takes over.
How it works
Each SwipeCard creates its own `x` useMotionValue, then derives `rotate` (mapped from [-200, 200] to [-15, 15] degrees) and `opacity` (a five-point fade toward the edges) via useTransform. The drag axis is locked to `x` with elastic set to 0.9, and dragConstraints returns the card to center when released below the threshold. The `onDragEnd` handler checks `info.offset.x`; if the absolute value exceeds 100px, it adds the card ID to the dismissed Set. AnimatePresence manages the exit animation, a quick 0.2s fade, so cards don't snap out.
How to build it in React
Build the dismissal state
Hold dismissed card IDs in a Set inside useState. Derive the visible stack by filtering the testimonials array against that Set. A reset function replaces the Set with a fresh empty one.
const [gone, setGone] = useState(new Set<string>()); const remaining = testimonials.filter((t) => !gone.has(t.id)); const dismiss = (id: string) => setGone((prev) => new Set(prev).add(id)); const reset = () => setGone(new Set());Create rotation and opacity from drag position
Inside SwipeCard, create an `x` motion value, then map it to rotation and opacity with useTransform. These run entirely on the compositor thread, no React re-renders during drag.
const x = useMotionValue(0); const rotate = useTransform(x, [-200, 200], [-15, 15]); const opacity = useTransform(x, [-200, -100, 0, 100, 200], [0.5, 1, 1, 1, 0.5]);Dismiss on threshold and animate out
Pass `drag="x"` with `dragElastic={0.9}` and `dragConstraints={{ left: 0, right: 0 }}` to the motion.div. In `onDragEnd`, read `info.offset.x`; call `onDismiss()` when it exceeds 100px in either direction. Wrap the card list in AnimatePresence with an exit fade.
onDragEnd={(_, info) => { if (Math.abs(info.offset.x) > 100) onDismiss(); }}Render at most three cards at depth
Slice the remaining array to three items, reverse it, then render each one. The last element after reversing is the front card; give it `isFront={true}` so it gets drag and live motion values. The others sit behind at 0.7 opacity as static depth cues.
{remaining.slice(0, 3).reverse().map((t, i, arr) => ( <SwipeCard key={t.id} testimonial={t} onDismiss={() => dismiss(t.id)} isFront={i === arr.length - 1} /> ))}
When to use it
Reach for this pattern when you want social proof to feel interactive rather than decorative, consumer apps, marketplace products, mobile-first SaaS, or any landing page targeting an audience comfortable with swipe gestures. Skip it on B2B enterprise pages where buyers expect dense logos and quotes at a glance, or when you have fewer than four testimonials (the stack effect disappears with two cards). Provide a way to see all testimonials at once for users who won't bother swiping through.
Used by
- Tinder, The originator of the swipe-to-dismiss card mechanic, now a widely understood interaction pattern.
- Bumble, Uses the same stack-swipe mechanic for profile cards, reinforcing how natural horizontal drag feels on touch.
- Product Hunt, Applies card-deck voting UI in its Golden Kitty awards flow, letting users swipe through nominees.
FAQ
Does the swipe work on mobile and touch screens?
Yes. Framer Motion's drag system listens on pointer events, which covers both mouse and touch. The 100px offset threshold translates naturally to a finger swipe distance.
Why does the stack show only three cards at a time?
Rendering more than three would stack DOM nodes unnecessarily and the depth illusion breaks past the third card anyway. Slicing to three keeps the component lean while still conveying 'there are more behind this one'.
How do I add a visual hint (arrow or label) showing which way to swipe?
Map the `x` motion value to an opacity for a left label and a right label using useTransform. Show 'Like' on the right when x > 20 and 'Skip' on the left when x < -20, both as absolutely positioned overlays on the card.
Can I trigger dismissal programmatically, without a drag?
Yes. Expose the `dismiss` function to parent buttons (like left/right arrow controls). For the exit animation to fire, make sure the card is still inside AnimatePresence when the ID is added to the dismissed Set.