Cards
InfiniteStack
Swipe the top card away, or focus the stack and press the arrow keys, and it goes to the back. The stack loops forever.
Download
- React
- Tailwind CSS v4
- motion
components/ui/infinite-stack.tsx
"use client";
import { useState, type ReactNode } from "react";
import { motion, useReducedMotion, type PanInfo } from "motion/react";
type InfiniteStackProps = {
/** One entry per card. Needs at least 2. */
items: ReactNode[];
className?: string;
/** How many cards show behind the top one. */
visible?: number;
/** Name read by screen readers. */
label?: string;
};
/* InfiniteStack: swipe or press the arrow keys to send the top card to the back. The stack never ends. */
export function InfiniteStack({
items,
className = "rounded-2xl border border-white/10 bg-[#1b1830] p-6 text-white",
visible = 3,
label = "Card stack",
}: InfiniteStackProps) {
const reduce = useReducedMotion();
const [order, setOrder] = useState(() => items.map((_, i) => i));
const n = items.length;
const next = () => setOrder((o) => [...o.slice(1), o[0]]);
const prev = () => setOrder((o) => [o[n - 1], ...o.slice(0, -1)]);
const onDragEnd = (_: unknown, info: PanInfo) => {
if (Math.abs(info.offset.x) > 80 || Math.abs(info.velocity.x) > 400) next();
};
return (
<div
role="group"
aria-roledescription="carousel"
aria-label={label}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); next(); }
if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); prev(); }
}}
className="relative isolate grid outline-none focus-visible:ring-2 focus-visible:ring-[#a78bfa] focus-visible:ring-offset-4 focus-visible:ring-offset-transparent rounded-2xl"
>
{order.map((item, pos) => {
const shown = pos <= visible;
const top = pos === 0;
return (
<motion.div
key={item}
aria-hidden={!top}
className={`col-start-1 row-start-1 touch-pan-y ${top ? "cursor-grab active:cursor-grabbing" : ""} ${className}`}
style={{ zIndex: n - pos }}
initial={false}
animate={{
y: Math.min(pos, visible) * 12,
scale: 1 - Math.min(pos, visible) * 0.05,
opacity: shown ? 1 - pos * 0.2 : 0,
}}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 260, damping: 26 }}
drag={top ? "x" : false}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.7}
onDragEnd={top ? onDragEnd : undefined}
>
{items[item]}
</motion.div>
);
})}
<p aria-live="polite" className="sr-only">
Card {order[0] + 1} of {n}
</p>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/infinite-stack.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/infinite-stack-demo.tsx
import { InfiniteStack } from "@/components/ui/infinite-stack"; const QUOTES = [ ["“Shipped our landing page in a day.”", "Priya, Chennai"], ["“The motion feels right out of the box.”", "Arjun, Bengaluru"], ["“Copy, paste, done. No setup.”", "Meera, Kochi"], ["“Our clients noticed the polish.”", "Karthik, Coimbatore"], ]; export default function InfiniteStackExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-6"> <div className="w-full max-w-[300px]"> <InfiniteStack label="Testimonials" items={QUOTES.map(([quote, who]) => ( <div key={who} className="select-none"> <p className="text-lg font-medium leading-snug">{quote}</p> <p className="mt-4 text-sm text-white/60">{who}</p> </div> ))} /> <p className="mt-10 text-center text-xs text-white/40">Swipe the card, or focus and use arrow keys.</p> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | ReactNode[] | — | One entry per card, at least 2. |
| visible | number | 3 | Cards shown behind the top one. |
| label | string | "Card stack" | Name read by screen readers. |
| className | string | dark violet card | Look of every card. |