Fanned Card Stack with Cycle Controls

A stack of four bright testimonial cards that fans out to one side like a hand of cards. The front card slides clear, drops to the back of the pile and the rest spring forward one place. It plays by itself, and visitors can use the next and previous buttons or drag the front card away. A headline and the controls sit beside the stack on desktop and above it on phones, where the fan tightens to fit.

Code

import { useCallback, useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";

const stories = [
  {
    quote: "We closed the books in two days instead of nine. I had forgotten what a quiet month end felt like.",
    name: "Ines Varga",
    role: "Finance lead, Pallet & Pine",
    initials: "IV",
    metric: "7 days",
    metricLabel: "saved every month",
    bg: "bg-lime-300",
  },
  {
    quote: "Receipts land, get matched and disappear. Nobody on my team has chased a missing invoice since March.",
    name: "Tobias Reyes",
    role: "Operations, Northlight Studio",
    initials: "TR",
    metric: "0",
    metricLabel: "missing invoices",
    bg: "bg-pink-300",
  },
  {
    quote: "It caught a duplicate supplier payment on day one. That single catch paid for three years of the plan.",
    name: "Amara Lindqvist",
    role: "Founder, Dovetail Bikes",
    initials: "AL",
    metric: "$14,200",
    metricLabel: "recovered in week one",
    bg: "bg-sky-300",
  },
  {
    quote: "Our accountant asked what we changed. Everything was labelled, in order and ready before she opened it.",
    name: "Kenji Marlowe",
    role: "Co-owner, Saltwater Bakery",
    initials: "KM",
    metric: "4.9 / 5",
    metricLabel: "from 2,300 teams",
    bg: "bg-amber-300",
  },
];

const HOLD = 2600; // ms each card stays in front while the stack plays by itself
const SWAP = 280; // ms the front card takes to slide clear before it tucks behind

export default function FannedCardStackWithCycleControls() {
  const reduce = useReducedMotion();
  const [order, setOrder] = useState(() => stories.map((_, i) => i)); // order[0] is the front card
  const [leaving, setLeaving] = useState<number | null>(null);
  const [fanned, setFanned] = useState(false);
  const [spread, setSpread] = useState(38);
  const orderRef = useRef(order);
  const wrapRef = useRef<HTMLDivElement>(null);
  const busy = useRef(false);
  const paused = useRef(false);
  const visible = useRef(true);
  const timers = useRef<number[]>([]);

  const cycle = useCallback(
    (dir: 1 | -1) => {
      if (busy.current) return;
      busy.current = true;
      const current = orderRef.current;
      // Forward: the front card slides clear and tucks in at the back. Backward: the last card comes round to the front.
      const mover = dir === 1 ? current[0] : current[current.length - 1];
      const next = dir === 1 ? [...current.slice(1), mover] : [mover, ...current.slice(0, -1)];
      setLeaving(mover);
      timers.current.push(
        window.setTimeout(() => {
          orderRef.current = next;
          setOrder(next);
          setLeaving(null);
          busy.current = false;
        }, reduce ? 0 : SWAP)
      );
    },
    [reduce]
  );

  useEffect(() => {
    const wrap = wrapRef.current;
    if (!wrap) return;
    const fit = () => setSpread(wrap.offsetWidth < 420 ? 15 : 38);
    fit();
    const ro = new ResizeObserver(fit);
    ro.observe(wrap);
    const io = new IntersectionObserver(([entry]) => {
      visible.current = entry.isIntersecting;
    });
    io.observe(wrap);
    const open = window.setTimeout(() => setFanned(true), 120);
    return () => {
      ro.disconnect();
      io.disconnect();
      window.clearTimeout(open);
      timers.current.forEach((t) => window.clearTimeout(t));
    };
  }, []);

  // Plays by itself until someone hovers, focuses or drags.
  useEffect(() => {
    if (reduce) return;
    const tick = () => {
      if (!paused.current && visible.current && !document.hidden) cycle(1);
    };
    let loop = 0;
    const first = window.setTimeout(() => {
      tick();
      loop = window.setInterval(tick, HOLD);
    }, 2100);
    return () => {
      window.clearTimeout(first);
      window.clearInterval(loop);
    };
  }, [cycle, reduce]);

  const front = order[0];
  const last = stories.length - 1;

  return (
    <section className="overflow-hidden bg-neutral-100 px-5 py-16 text-neutral-900 sm:px-6 sm:py-24">
      <div className="mx-auto grid max-w-5xl items-center gap-14 lg:grid-cols-2 lg:gap-8">
        <div>
          <p className="inline-flex items-center gap-2 rounded-full bg-neutral-900 px-3 py-1 text-xs font-semibold text-white">
            <span className="h-1.5 w-1.5 rounded-full bg-lime-300" />
            Ledgerline for small teams
          </p>
          <h2 className="mt-5 text-4xl font-semibold tracking-tight sm:text-5xl">Month end, without the month of dread.</h2>
          <p className="mt-5 max-w-md text-base leading-7 text-neutral-600">Bookkeeping that files itself while you work. Flick through what a few of the teams using it have to say.</p>

          <div
            className="mt-9 flex items-center gap-4"
            onMouseEnter={() => (paused.current = true)}
            onMouseLeave={() => (paused.current = false)}
            onFocus={() => (paused.current = true)}
            onBlur={() => (paused.current = false)}
          >
            <button
              type="button"
              onClick={() => cycle(-1)}
              aria-label="Previous story"
              className="flex h-12 w-12 items-center justify-center rounded-full border border-neutral-300 bg-white text-neutral-900 transition hover:border-neutral-900 active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-100"
            >
              <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
                <path d="M12 4.5 6.5 10l5.5 5.5" />
              </svg>
            </button>
            <button
              type="button"
              onClick={() => cycle(1)}
              aria-label="Next story"
              className="flex h-12 w-12 items-center justify-center rounded-full bg-neutral-900 text-white transition hover:bg-neutral-700 active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-100"
            >
              <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
                <path d="M8 4.5 13.5 10 8 15.5" />
              </svg>
            </button>
            <p className="text-sm font-medium tabular-nums text-neutral-600" aria-live="polite">
              <span className="text-neutral-900">{String(front + 1).padStart(2, "0")}</span> / {String(stories.length).padStart(2, "0")}
              <span className="sr-only">: {stories[front].name}</span>
            </p>
            <div className="flex items-center gap-1.5" aria-hidden="true">
              {stories.map((s, i) => (
                <span key={s.name} className={`h-1.5 rounded-full transition-all duration-500 motion-reduce:transition-none ${i === front ? "w-6 bg-neutral-900" : "w-1.5 bg-neutral-300"}`} />
              ))}
            </div>
          </div>
        </div>

        <div
          ref={wrapRef}
          className="flex justify-center"
          onMouseEnter={() => (paused.current = true)}
          onMouseLeave={() => (paused.current = false)}
        >
          <ul className="relative h-[400px] w-[264px] sm:h-[430px] sm:w-[330px]" style={{ marginRight: spread * last }}>
            {stories.map((s, i) => {
              const pos = order.indexOf(i);
              const isFront = pos === 0;
              const out = leaving === i;
              const pose = out
                ? { x: -(spread * 2 + 90), y: 14, rotate: -13, scale: pos === 0 ? 1 : 0.9 }
                : fanned
                  ? { x: pos * spread, y: pos * -7, rotate: pos * 4.2, scale: 1 - pos * 0.055 }
                  : { x: 0, y: pos * -5, rotate: 0, scale: 1 - pos * 0.03 };
              return (
                <motion.li
                  key={s.name}
                  initial={false}
                  animate={pose}
                  transition={reduce ? { duration: 0 } : out ? { duration: SWAP / 1000, ease: [0.3, 0, 0.6, 1] } : { type: "spring", stiffness: 210, damping: 22, mass: 0.9, delay: fanned && leaving === null ? pos * 0.03 : 0 }}
                  drag={isFront && !reduce ? "x" : false}
                  dragConstraints={{ left: 0, right: 0 }}
                  dragElastic={0.55}
                  onDragStart={() => (paused.current = true)}
                  onDragEnd={(_, info) => {
                    paused.current = false;
                    if (Math.abs(info.offset.x) > 70) cycle(1);
                  }}
                  style={{ zIndex: stories.length - pos, transformOrigin: "50% 100%", touchAction: "pan-y" }}
                  className={`absolute inset-0 flex select-none flex-col rounded-[28px] p-6 shadow-xl shadow-neutral-900/15 ring-1 ring-neutral-900/10 sm:p-7 ${s.bg} ${isFront ? "cursor-grab active:cursor-grabbing" : ""}`}
                >
                  <div className="flex items-start justify-between">
                    <svg viewBox="0 0 40 32" fill="currentColor" aria-hidden="true" className="h-8 w-10 text-neutral-900">
                      <path d="M0 32V18C0 8 5 2 15 0l2 5c-5 2-7 5-7 9h7v18H0Zm23 0V18c0-10 5-16 15-18l2 5c-5 2-7 5-7 9h7v18H23Z" />
                    </svg>
                    <span className="rounded-full bg-neutral-900/10 px-3 py-1 text-xs font-semibold tabular-nums">{String(i + 1).padStart(2, "0")}</span>
                  </div>
                  {/* Cards waiting behind keep their colour and number but hide their words, so the fan stays clean. */}
                  <div className={`mt-6 flex flex-1 flex-col transition-opacity duration-300 motion-reduce:transition-none ${isFront && !out ? "opacity-100" : "opacity-0"}`}>
                  <blockquote className="text-xl font-semibold leading-snug tracking-tight sm:text-2xl sm:leading-snug">{s.quote}</blockquote>
                  <div className="mt-auto">
                    <p className="flex items-baseline gap-2 border-t border-neutral-900/15 pt-4">
                      <span className="text-2xl font-semibold tracking-tight">{s.metric}</span>
                      <span className="text-xs font-medium text-neutral-800">{s.metricLabel}</span>
                    </p>
                    <div className="mt-4 flex items-center gap-3">
                      <span className="flex h-10 w-10 flex-none items-center justify-center rounded-full bg-neutral-900 text-xs font-semibold text-white">{s.initials}</span>
                      <p className="text-sm leading-tight">
                        <span className="block font-semibold">{s.name}</span>
                        <span className="text-neutral-800">{s.role}</span>
                      </p>
                    </div>
                  </div>
                  </div>
                </motion.li>
              );
            })}
          </ul>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/fanned-card-stack-with-cycle-controls.json

When to use it

How to customize it

Accessibility

Questions

How does the stacked card animation work?

An `order` array holds which card is in which position. Each card reads its position and animates to a pose worked out from it: further back means further right, more rotated, slightly smaller and a lower `z-index`. Changing the array is all it takes to move every card, and framer-motion springs each one to its new pose.

How does the front card get behind the others without cutting through them?

It happens in two steps. First the front card slides sideways until it is mostly clear of the pile. Then the order changes, which drops its `z-index` to the bottom, and it springs back in underneath.

Can visitors swipe the cards?

Yes. The front card is draggable sideways. Past 70 pixels it counts as a flick and the stack advances; less than that and it springs back. Vertical swipes still scroll the page because the cards use `touch-action: pan-y`.

How do I stop it autoplaying?

Remove the second `useEffect`, the one that calls `tick`. The buttons and drag keep working. To only slow it down, raise `HOLD`.

Why framer-motion and not CSS transitions?

Springs that can be interrupted mid-flight and drag with a snap back are a few props in framer-motion and a lot of code by hand. If you only need the buttons, the poses could be moved to CSS transforms with a transition.

Related: Testimonial Cards with Star Ratings and Initials Avatars, Large Quote Testimonial with Author and Role, Flip Cards with Details on the Back.

More animated cards or all free UI components. Start for free to have a full site built around it.

More in Animated Cards

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap