Draggable Card Stack Carousel with Spring Physics

A loose pile of illustrated cards that works as a carousel. Drag the top card sideways and it tilts with your hand; let go past the threshold, or flick it quickly, and it flies off, then slides back in underneath the pile while the next card springs forward. A short drag snaps back. Arrows and dots do the same job for keyboards, and the deck deals itself every few seconds until someone touches it. On phones the text sits above the stack and vertical page scrolling still works over the cards.

Code

import { useCallback, useEffect, useRef, useState } from "react";
import { animate, motion, motionValue, useInView, useMotionValue, useReducedMotion, useTransform } from "framer-motion";
import type { MotionValue } from "framer-motion";

type Trip = { id: string; place: string; region: string; kind: string; nights: string; price: string; sky: [string, string]; sun: string; sunAt: [number, number]; hills: [string, string, string] };

// The cards in the deck, top card first.
const trips: Trip[] = [
  { id: "saltmarsh", place: "Saltmarsh Bay", region: "North coast", kind: "Coast", nights: "2 nights", price: "$240", sky: ["#fb7185", "#fed7aa"], sun: "#fff7ed", sunAt: [210, 150], hills: ["#f97316", "#c2410c", "#7c2d12"] },
  { id: "juniper", place: "Juniper Ridge", region: "High country", kind: "Mountains", nights: "3 nights", price: "$410", sky: ["#1d4ed8", "#bae6fd"], sun: "#fef9c3", sunAt: [86, 110], hills: ["#60a5fa", "#2563eb", "#172554"] },
  { id: "fernhollow", place: "Fern Hollow", region: "Rainforest valley", kind: "Forest", nights: "2 nights", price: "$285", sky: ["#065f46", "#d9f99d"], sun: "#fef08a", sunAt: [150, 190], hills: ["#4ade80", "#15803d", "#052e16"] },
  { id: "amberdunes", place: "Amber Dunes", region: "Desert edge", kind: "Desert", nights: "1 night", price: "$165", sky: ["#f59e0b", "#fef3c7"], sun: "#ffffff", sunAt: [230, 96], hills: ["#fbbf24", "#d97706", "#78350f"] },
  { id: "lanternquay", place: "Lantern Quay", region: "Old harbour town", kind: "City", nights: "2 nights", price: "$320", sky: ["#4c1d95", "#f9a8d4"], sun: "#fde68a", sunAt: [120, 130], hills: ["#c084fc", "#7e22ce", "#2e1065"] },
];

const SPRING = { type: "spring", stiffness: 260, damping: 24 } as const;
const FLING = 520; // how far a dismissed card travels, in pixels
const AUTOPLAY_MS = 2400;

// How each place in the pile sits: the top card, then the three behind it.
const pile = [
  { x: 0, y: 0, rotate: 0, scale: 1, opacity: 1 },
  { x: 24, y: 10, rotate: 6, scale: 0.955, opacity: 1 },
  { x: -26, y: 20, rotate: -7, scale: 0.91, opacity: 1 },
  { x: 38, y: 30, rotate: 12, scale: 0.865, opacity: 1 },
  { x: 0, y: 40, rotate: 0, scale: 0.82, opacity: 0 },
];

function Scene({ trip }: { trip: Trip }) {
  const g = `stack-sky-${trip.id}`;
  return (
    <svg viewBox="0 0 300 420" preserveAspectRatio="xMidYMid slice" aria-hidden="true" className="absolute inset-0 h-full w-full">
      <defs>
        <linearGradient id={g} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor={trip.sky[0]} /><stop offset="0.7" stopColor={trip.sky[1]} /></linearGradient>
      </defs>
      <rect width="300" height="420" fill={`url(#${g})`} />
      <circle cx={trip.sunAt[0]} cy={trip.sunAt[1]} r="62" fill={trip.sun} opacity="0.25" />
      <circle cx={trip.sunAt[0]} cy={trip.sunAt[1]} r="40" fill={trip.sun} />
      <path d="M0 250 C50 200 90 215 130 240 S220 190 300 230 V420 H0Z" fill={trip.hills[0]} />
      <path d="M0 300 C60 262 110 300 160 282 S250 250 300 286 V420 H0Z" fill={trip.hills[1]} />
      <path d="M0 352 C70 318 120 356 180 338 S260 322 300 344 V420 H0Z" fill={trip.hills[2]} />
    </svg>
  );
}

function Card({ trip, pos, x, lead, onRelease, draggable, onTouch, reduce, entered }: { reduce: boolean; entered: boolean; trip: Trip; pos: number; x: MotionValue<number>; lead: MotionValue<number>; onRelease: (offset: number, velocity: number) => void; draggable: boolean; onTouch: () => void }) {
  const place = pile[Math.min(pos, pile.length - 1)];
  const posRef = useRef(pos);
  posRef.current = pos;
  const rotate = useTransform(x, [-FLING, 0, FLING], [-22, 0, 22]);
  const opacity = useTransform(x, [-FLING, -FLING * 0.55, 0, FLING * 0.55, FLING], [0, 1, 1, 1, 0]);
  // The card under the top one swells a little as the top card is pulled away.
  const swell = useTransform(lead, (v) => (posRef.current === 1 ? 1 + 0.045 * Math.min(Math.abs(v) / 200, 1) : 1));
  return (
    <motion.li
      className="absolute inset-0 list-none"
      style={{ zIndex: trips.length - pos }}
      initial={reduce ? false : { x: 0, y: 90, rotate: 0, scale: 0.8, opacity: 0 }}
      animate={place}
      transition={reduce ? { duration: 0 } : { ...SPRING, delay: entered ? 0 : 0.1 + (3 - Math.min(pos, 3)) * 0.09 }}
      aria-hidden={pos !== 0}
    >
      <motion.div
        className={`relative h-full w-full select-none overflow-hidden rounded-[28px] bg-stone-900 text-white shadow-xl shadow-rose-950/20 ring-1 ring-black/5 ${draggable ? "cursor-grab active:cursor-grabbing" : ""}`}
        style={{ x, rotate, opacity, scale: swell, touchAction: "pan-y" }}
        drag={draggable ? "x" : false}
        dragMomentum={false}
        dragElastic={1}
        onPointerDown={onTouch}
        onDragEnd={(_, info) => onRelease(info.offset.x, info.velocity.x)}
      >
        <Scene trip={trip} />
        <div aria-hidden="true" className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/70 to-transparent" />
        <div className="relative flex h-full flex-col justify-between p-6">
          <div className="flex items-center justify-between">
            <span className="rounded-full bg-white/90 px-3 py-1 text-xs font-semibold text-stone-900">{trip.kind}</span>
            <span className="rounded-full bg-black/35 px-3 py-1 text-xs font-medium backdrop-blur-sm">{trip.nights}</span>
          </div>
          <div>
            <p className="text-sm font-medium text-white/80">{trip.region}</p>
            <h3 className="mt-1 text-3xl font-semibold tracking-tight">{trip.place}</h3>
            <div className="mt-4 flex items-center justify-between border-t border-white/25 pt-4">
              <p className="text-sm text-white/80">From <span className="text-lg font-semibold text-white">{trip.price}</span></p>
              <span className="text-sm font-semibold">View trip</span>
            </div>
          </div>
        </div>
      </motion.div>
    </motion.li>
  );
}

function Arrow({ dir }: { dir: "left" | "right" }) {
  return (
    <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <path d={dir === "left" ? "M16 10H4M9 5l-5 5 5 5" : "M4 10h12M11 5l5 5-5 5"} />
    </svg>
  );
}

export default function DraggableCardStackCarouselWithSpringPhysics() {
  const n = trips.length;
  const reduce = useReducedMotion();
  const section = useRef<HTMLElement>(null);
  const inView = useInView(section, { amount: 0.3 });
  const [top, setTop] = useState(0);
  const [touched, setTouched] = useState(false);
  const [xs] = useState(() => trips.map(() => motionValue(0)));
  const lead = useMotionValue(0);
  const busy = useRef(false);
  const timer = useRef(0);
  const side = useRef(1);
  const [entered, setEntered] = useState(false);

  useEffect(() => {
    const id = window.setTimeout(() => setEntered(true), 900);
    return () => window.clearTimeout(id);
  }, []);

  // `lead` mirrors the top card's drag position so the card beneath can react to it.
  useEffect(() => {
    lead.set(xs[top].get());
    return xs[top].on("change", (v) => lead.set(v));
  }, [top, xs, lead]);

  useEffect(() => () => window.clearTimeout(timer.current), []);

  // Throw the top card off to one side, then tuck it under the pile.
  const fling = useCallback((dir: number, velocity = 0) => {
    if (busy.current) return;
    busy.current = true;
    const i = top;
    if (reduce) {
      setTop((i + 1) % n);
      xs[i].set(0);
      busy.current = false;
      return;
    }
    animate(xs[i], dir * FLING, { type: "spring", stiffness: 200, damping: 26, velocity });
    timer.current = window.setTimeout(() => {
      setTop((i + 1) % n);
      animate(xs[i], 0, { type: "spring", stiffness: 210, damping: 26 });
      busy.current = false;
    }, 260);
  }, [top, n, xs, reduce]);

  // Bring the last card back in from the side.
  const back = useCallback(() => {
    if (busy.current) return;
    const i = (top - 1 + n) % n;
    if (!reduce) xs[i].set(-FLING);
    setTop(i);
    if (!reduce) animate(xs[i], 0, SPRING);
  }, [top, n, xs, reduce]);

  const jump = (i: number) => {
    if (busy.current || i === top) return;
    xs.forEach((v) => v.set(0));
    setTop(i);
  };

  const release = (offset: number, velocity: number) => {
    if (Math.abs(offset) > 110 || Math.abs(velocity) > 650) fling(offset === 0 ? Math.sign(velocity) || 1 : Math.sign(offset), velocity);
    else animate(xs[top], 0, { type: "spring", stiffness: 420, damping: 22 });
  };

  // Deal a card every few seconds until the visitor takes over.
  const playing = inView && !touched && !reduce;
  useEffect(() => {
    if (!playing) return;
    const id = window.setInterval(() => {
      if (document.hidden) return;
      side.current = -side.current;
      fling(side.current);
    }, AUTOPLAY_MS);
    return () => window.clearInterval(id);
  }, [playing, fling]);

  const control = "flex h-11 w-11 items-center justify-center rounded-full border border-stone-900/15 bg-white text-stone-900 shadow-sm transition hover:-translate-y-0.5 hover:border-rose-500 hover:text-rose-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fff5ef]";

  return (
    <section ref={section} className="overflow-hidden bg-[#fff5ef] px-6 py-16 text-stone-900 sm:py-20">
      <div className="mx-auto grid max-w-5xl items-center gap-14 lg:grid-cols-2">
        <div className="mx-auto max-w-md text-center lg:mx-0 lg:text-left">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-rose-600">Weekends, sorted</p>
          <h2 className="mt-4 text-4xl font-semibold leading-[1.05] tracking-tight sm:text-5xl">Flick through five places worth the drive.</h2>
          <p className="mt-5 text-lg leading-relaxed text-stone-600">
            Each card is a ready-made short break with the stay, the route and one very good dinner. Drag a card away to see the next.
          </p>
          <a href="#" className="mt-8 inline-flex h-12 items-center rounded-full bg-stone-900 px-7 text-sm font-semibold text-white transition hover:bg-rose-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fff5ef]">
            Plan my weekend
          </a>
        </div>

        <div className="flex flex-col items-center" role="group" aria-roledescription="carousel" aria-label="Weekend trips" onFocus={() => setTouched(true)}>
          <ul className="relative m-0 h-[400px] w-[280px] p-0 sm:h-[430px] sm:w-[310px]">
            {trips.map((t, i) => (
              <Card key={t.id} trip={t} pos={(i - top + n) % n} x={xs[i]} lead={lead} draggable={i === top} onRelease={release} onTouch={() => setTouched(true)} reduce={!!reduce} entered={entered} />
            ))}
          </ul>

          <div className="mt-14 flex items-center gap-4">
            <button type="button" aria-label="Previous trip" onClick={() => { setTouched(true); back(); }} className={control}><Arrow dir="left" /></button>
            <div className="flex items-center" role="group" aria-label="Choose a trip">
              {trips.map((t, i) => (
                <button key={t.id} type="button" aria-label={`Show ${t.place}`} aria-current={i === top} onClick={() => { setTouched(true); jump(i); }} className="group flex h-10 w-6 items-center justify-center focus:outline-none">
                  <span className={`block h-2 rounded-full transition-all duration-300 group-focus-visible:ring-2 group-focus-visible:ring-rose-500 group-focus-visible:ring-offset-2 ${i === top ? "w-5 bg-rose-600" : "w-2 bg-stone-900/20 group-hover:bg-stone-900/50"}`} />
                </button>
              ))}
            </div>
            <button type="button" aria-label="Next trip" onClick={() => { setTouched(true); fling(-1); }} className={control}><Arrow dir="right" /></button>
          </div>
          <p className="mt-3 text-sm text-stone-600" aria-live={touched ? "polite" : "off"}>
            <span className="font-semibold text-stone-900">{trips[top].place}</span> <span aria-hidden="true">/</span> {top + 1} of {n}
          </p>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/draggable-card-stack-carousel-with-spring-physics.json

When to use it

How to customize it

Accessibility

Questions

How does the drag and throw work?

Every card owns a motion value for its horizontal position. The top card has `drag="x"`, and its tilt and opacity are derived from that value with `useTransform`. On release the component checks distance and velocity. Past the threshold it animates the value out with a spring that inherits the flick velocity, moves the card to the back of the order, then springs the value back to zero so the card slides in under the pile.

Why does the card behind grow as I drag?

A shared motion value mirrors the top card's position. The second card maps it to a small scale increase, so it rises to meet you as the top card leaves. No state updates happen during the drag, so it stays smooth.

Can the page still scroll when I touch a card?

Yes. The cards set `touch-action: pan-y`, so vertical swipes scroll the page and only horizontal movement drags the card.

How do I make it swipe left for no and right for yes?

`release` already knows the direction. Pass it to your own handler before calling `fling`, for example to save a like when the offset is positive.

Does the order loop?

Yes. A dismissed card goes to the bottom of the pile, so the deck never runs out. The Previous button brings the bottom card back in from the side.

Related: Coverflow Gallery with 3D Perspective and Autoplay, Masonry Gallery with Staggered Reveal and Hover Lift, Product Card with Price, Rating, Colour Swatches and Add to Cart.

More galleries and carousels or all free UI components. Start for free to have a full site built around it.

More in Galleries

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