uicapsule
blockcard-stacksource: ok · hit
Card Stack
npx shadcn add "https://uicapsule.com/r/card-stack.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationgsap
Files2 · source cached 10/15/2026, 5:43:52 AM
/card-stack.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
"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;