Retour au catalogue

Testimonials Stack Swipe

Pile de cartes temoignages a swiper style Tinder avec physique de ressort. Drag interactif gauche/droite.

testimonialscomplex Both Responsive a11y
playfulboldsaasagencyuniversalcentered
Theme

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

  1. 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());
  2. 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]);
  3. 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();
    }}
  4. 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.

"use client";

import { useState } from "react";
import { motion, useMotionValue, useTransform, AnimatePresence } from "framer-motion";
import { Star } from "lucide-react";

interface Testimonial {
  id: string;
  content: string;
  authorName: string;
  authorRole: string;
  rating: number;
}

interface TestimonialsStackSwipeProps {
  title?: string;
  subtitle?: string;
  testimonials?: Testimonial[];
}

const EASE = [0.16, 1, 0.3, 1] as const;

Code complet réservé à Pro

Code source intégral, export multi-framework et playground.

Passer en Pro, 9,99€/mois

Reviews

React Swipeable Testimonials Card Stack, Framer Motion