# Scrollable Card Stack

> An infinite stack of cards placed with perspective one on top of another.

A shadcn/ui block from the [@uicapsule](https://uicapsule.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/card-stack).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/card-stack
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/card-stack-light-cdd3dd7872ae0afd.png
- Registry item: https://uicapsule.com/r/card-stack.json
- Categories: cards-grids, minimal
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/card-stack.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@gsap/react`, `gsap`

## Files

### `/card-stack.tsx`

```tsx
import { useRef } from "react";
import { useGSAP } from "@gsap/react";
import { gsap } from "gsap";

interface CardStackProps {
  cards: { src: string; href: string; alt: string }[];
}

/** Wheel delta (px) required to advance the stack by one timeline second. */
const WHEEL_PIXELS_PER_SECOND = 1000;
/** Max tilt (deg) applied across the full height/width of the viewport. */
const TILT_X_RANGE = 5;
const TILT_Y_RANGE = 10;

const setContentTransformOnRepeat = (transform: string) =>
  function onRepeat(this: gsap.core.Tween) {
    const [target] = this.targets();
    if (target instanceof HTMLElement) {
      target.style.transform = transform;
    }
  };

export const CardStack = ({ cards }: CardStackProps) => {
  const rootRef = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    let incr = 0;

    const root = rootRef.current;
    if (!root) {
      return;
    }

    const slides = root.querySelectorAll(".slide");
    const slideContent = root.querySelectorAll(".content");

    if (slides.length === 0) {
      return;
    }

    const deltaObject = { delta: 0 };

    const baseDuration = slides.length / 2;
    const staggerEach = 0.5;
    const repeatDelay = baseDuration - staggerEach;

    const tl = gsap.timeline({ paused: true, smoothChildTiming: true });

    const deltaTo = gsap.quickTo(deltaObject, "delta", {
      duration: 0.8,
      ease: "power1",
      onUpdate: () => {
        tl.totalTime(deltaObject.delta);
      },
    });
    const rotY = gsap.quickTo(root, "rotationY", {
      duration: 0.3,
      ease: "power3",
    });
    const rotX = gsap.quickTo(root, "rotationX", {
      duration: 0.3,
      ease: "power3",
    });

    tl.fromTo(
      slides,
      {
        force3D: true,
        y: "-15vw",
        z: "-60vw",
      },
      {
        duration: baseDuration,
        ease: "none",
        force3D: true,
        immediateRender: false,
        stagger: {
          each: staggerEach,
          repeat: -1,
          repeatDelay: 0,
        },
        y: "0vw",
        z: "0vw",
      },
    );

    tl.fromTo(
      slideContent,
      {
        y: "10vh",
      },
      {
        duration: staggerEach,
        ease: "back.out(1.05)",
        stagger: {
          each: staggerEach,
          onRepeat: setContentTransformOnRepeat("translateY(100vh)"),
          repeat: -1,
          repeatDelay,
        },
        y: 0,
      },
      "<",
    );

    tl.fromTo(
      slideContent,
      {
        y: 0,
      },
      {
        delay: repeatDelay,
        duration: staggerEach,
        ease: "power3.in",
        stagger: {
          each: staggerEach,
          onRepeat: setContentTransformOnRepeat("translateY(0vh)"),
          repeat: -1,
          repeatDelay,
        },
        y: "200vh",
      },
      "<",
    );

    const beginDistance = slides.length * 100;

    tl.totalTime(beginDistance);

    deltaTo(beginDistance + 0.01, beginDistance);

    const snap = gsap.utils.snap(baseDuration / slides.length);

    const handleWheel = (e: WheelEvent) => {
      e.preventDefault();
      incr -= e.deltaY / WHEEL_PIXELS_PER_SECOND;
      deltaTo(snap(incr + beginDistance));
    };

    const handleMouseMove = (e: MouseEvent) => {
      const valX = (e.clientY / window.innerHeight - 0.5) * TILT_X_RANGE;
      const valY = (e.clientX / window.innerWidth - 0.5) * TILT_Y_RANGE;
      rotX(-valX);
      rotY(valY);
    };

    root.addEventListener("wheel", handleWheel, { passive: false });
    root.addEventListener("mousemove", handleMouseMove);

    return () => {
      root.removeEventListener("wheel", handleWheel);
      root.removeEventListener("mousemove", handleMouseMove);
    };
  });

  return (
    <section className="effect h-screen overflow-hidden bg-[#121212] text-[#f1f1f1] [perspective:150vw]">
      <div className="root h-full [transform-style:preserve-3d]" ref={rootRef}>
        {cards.map((card) => (
          <a
            key={card.src}
            className="slide absolute top-[calc(50%-5vw)] left-[calc(50%-20vw)] block [aspect-ratio:1.75] w-[40vw]"
            href={card.href}
            target="_blank"
          >
            <img
              alt={card.alt}
              className="content h-full w-full rounded-[12px] object-cover"
              src={card.src}
            />
          </a>
        ))}
      </div>
    </section>
  );
};
```

### `/preview.tsx`

```tsx
"use client";

import { CardStack } from "./card-stack";

const rootUrl = "https://d24l2zb4cwkekfpl.public.blob.vercel-storage.com/card-stack-1";

const cards = [
  { alt: "UI Capsule", href: "https://www.uicapsule.com", src: `${rootUrl}/uic.webp` },
  { alt: "KYH", href: "https://kyh.io", src: `${rootUrl}/kyh.webp` },
  { alt: "Vibed Games", href: "https://vibedgames.com", src: `${rootUrl}/vg.webp` },
  { alt: "Founding", href: "https://founding.so", src: `${rootUrl}/found.webp` },
  { alt: "Init", href: "https://init.kyh.io", src: `${rootUrl}/init.webp` },
  { alt: "TC", href: "https://tc.kyh.io", src: `${rootUrl}/tc.webp` },
  { alt: "Data Embed", href: "https://dataembed.com", src: `${rootUrl}/data.webp` },
  { alt: "Yours Sincerely", href: "https://yourssincerely.org", src: `${rootUrl}/ys.webp` },
];

const Preview = () => <CardStack cards={cards} />;

export default Preview;
```
