Card Swap
An animated stack of cards that cycle in 3D on an interval.
Loading…
import { Card, CardSwap } from "@/components/ui/card-swap";const cards = [ { title: "Reliable", description: "99.99% uptime backed by a global edge network.", }, { title: "Fast", description: "Sub-50ms responses from the region nearest your users.", }, { title: "Secure", description: "Encrypted in transit and at rest by default.", },];export default function CardSwapDemo() { return ( <div className="relative h-[400px] w-full overflow-hidden"> <CardSwap width={360} height={250} cardDistance={50} verticalDistance={55} delay={3500} pauseOnHover > {cards.map((card) => ( <Card key={card.title} className="p-6 text-white"> <h3 className="text-lg font-semibold">{card.title}</h3> <p className="mt-2 text-sm text-white/70">{card.description}</p> </Card> ))} </CardSwap> </div> );}Installation
npx shadcn@latest add @cortexcn/card-swapInstall the following dependencies:
npm install gsapCopy and paste the following code into your project.
"use client";import gsap from "gsap";import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactElement, ReactNode, Ref,} from "react";import React, { Children, cloneElement, forwardRef, isValidElement, useEffect, useMemo, useRef,} from "react";import { cn } from "@/lib/utils";export interface CardProps extends React.HTMLAttributes<HTMLDivElement> { customClass?: string;}export const Card = forwardRef<HTMLDivElement, CardProps>( ({ customClass, className, ...rest }, ref) => ( <div ref={ref} {...rest} className={cn( "absolute top-1/2 left-1/2 rounded-xl border border-white bg-black [backface-visibility:hidden] [transform-style:preserve-3d] [will-change:transform]", customClass, className, )} /> ),);Card.displayName = "Card";type Slot = { x: number; y: number; z: number; zIndex: number };const makeSlot = ( i: number, distX: number, distY: number, total: number,): Slot => ({ x: i * distX, y: -i * distY, z: -i * distX * 1.5, zIndex: total - i,});const placeNow = (el: HTMLElement, slot: Slot, skew: number) => gsap.set(el, { x: slot.x, y: slot.y, z: slot.z, xPercent: -50, yPercent: -50, skewY: skew, transformOrigin: "center center", zIndex: slot.zIndex, force3D: true, });export interface CardSwapProps { width?: number | string; height?: number | string; cardDistance?: number; verticalDistance?: number; delay?: number; pauseOnHover?: boolean; onCardClick?: (idx: number) => void; skewAmount?: number; easing?: "linear" | "elastic"; children: ReactNode;}type CardElementProps = { style?: CSSProperties; onClick?: (e: ReactMouseEvent<HTMLDivElement>) => void; ref?: Ref<HTMLDivElement>;};export function CardSwap({ width = 500, height = 400, cardDistance = 60, verticalDistance = 70, delay = 5000, pauseOnHover = false, onCardClick, skewAmount = 6, easing = "elastic", children,}: CardSwapProps) { const config = easing === "elastic" ? { ease: "elastic.out(0.6,0.9)", durDrop: 2, durMove: 2, durReturn: 2, promoteOverlap: 0.9, returnDelay: 0.05, } : { ease: "power1.inOut", durDrop: 0.8, durMove: 0.8, durReturn: 0.8, promoteOverlap: 0.45, returnDelay: 0.2, }; const childArr = useMemo(() => Children.toArray(children), [children]); const refs = useMemo( () => childArr.map(() => React.createRef<HTMLDivElement>()), // eslint-disable-next-line react-hooks/exhaustive-deps [childArr.length], ); const order = useRef(Array.from({ length: childArr.length }, (_, i) => i)); const tlRef = useRef<ReturnType<typeof gsap.timeline> | null>(null); const intervalRef = useRef<number | undefined>(undefined); const container = useRef<HTMLDivElement>(null); useEffect(() => { const total = refs.length; refs.forEach((r, i) => { if (r.current) { placeNow( r.current, makeSlot(i, cardDistance, verticalDistance, total), skewAmount, ); } }); const swap = () => { if (order.current.length < 2) return; const [front, ...rest] = order.current; const elFront = refs[front].current; if (!elFront) return; const tl = gsap.timeline(); tlRef.current = tl; tl.to(elFront, { y: "+=500", duration: config.durDrop, ease: config.ease, }); tl.addLabel("promote", `-=${config.durDrop * config.promoteOverlap}`); rest.forEach((idx, i) => { const el = refs[idx].current; if (!el) return; const slot = makeSlot(i, cardDistance, verticalDistance, refs.length); tl.set(el, { zIndex: slot.zIndex }, "promote"); tl.to( el, { x: slot.x, y: slot.y, z: slot.z, duration: config.durMove, ease: config.ease, }, `promote+=${i * 0.15}`, ); }); const backSlot = makeSlot( refs.length - 1, cardDistance, verticalDistance, refs.length, ); tl.addLabel("return", `promote+=${config.durMove * config.returnDelay}`); tl.call( () => { gsap.set(elFront, { zIndex: backSlot.zIndex }); }, undefined, "return", ); tl.to( elFront, { x: backSlot.x, y: backSlot.y, z: backSlot.z, duration: config.durReturn, ease: config.ease, }, "return", ); tl.call(() => { order.current = [...rest, front]; }); }; swap(); intervalRef.current = window.setInterval(swap, delay); if (pauseOnHover) { const node = container.current; if (node) { const pause = () => { tlRef.current?.pause(); if (intervalRef.current) clearInterval(intervalRef.current); }; const resume = () => { tlRef.current?.play(); intervalRef.current = window.setInterval(swap, delay); }; node.addEventListener("mouseenter", pause); node.addEventListener("mouseleave", resume); return () => { node.removeEventListener("mouseenter", pause); node.removeEventListener("mouseleave", resume); if (intervalRef.current) clearInterval(intervalRef.current); }; } } return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [cardDistance, verticalDistance, delay, pauseOnHover, skewAmount, easing]); const rendered = childArr.map((child, i) => { if (!isValidElement(child)) return child; const el = child as ReactElement<CardElementProps>; return cloneElement(el, { key: i, ref: refs[i], style: { width, height, ...(el.props.style ?? {}) }, onClick: (e: ReactMouseEvent<HTMLDivElement>) => { el.props.onClick?.(e); onCardClick?.(i); }, }); }); return ( <div ref={container} className="absolute right-0 bottom-0 origin-bottom-right translate-x-[5%] translate-y-[20%] overflow-visible max-[768px]:translate-x-[25%] max-[768px]:translate-y-[25%] max-[768px]:scale-75 max-[480px]:scale-[0.55]" style={{ width, height, perspective: "900px" }} > {rendered} </div> );}Update the import paths to match your project setup.
Usage
CardSwap is positioned absolutely in the bottom-right of its nearest
positioned ancestor, so wrap it in a relative container with a fixed height.
import { Card, CardSwap } from "@/components/ui/card-swap";<div className="relative h-[400px] w-full overflow-hidden">
<CardSwap width={360} height={250} delay={3500} pauseOnHover>
<Card className="p-6 text-white">
<h3 className="text-lg font-semibold">Reliable</h3>
<p className="mt-2 text-sm text-white/70">
99.99% uptime backed by a global edge network.
</p>
</Card>
<Card className="p-6 text-white">
<h3 className="text-lg font-semibold">Fast</h3>
<p className="mt-2 text-sm text-white/70">
Sub-50ms responses from the region nearest your users.
</p>
</Card>
<Card className="p-6 text-white">
<h3 className="text-lg font-semibold">Secure</h3>
<p className="mt-2 text-sm text-white/70">
Encrypted in transit and at rest by default.
</p>
</Card>
</CardSwap>
</div>Credits
Adapted from React Bits and vendored into the library. The animation is powered by GSAP.
API Reference
CardSwap
| Prop | Type | Default | Description |
|---|---|---|---|
width | number | string | 500 | Width of each card. |
height | number | string | 400 | Height of each card. |
cardDistance | number | 60 | Horizontal offset between stacked cards. |
verticalDistance | number | 70 | Vertical offset between stacked cards. |
delay | number | 5000 | Time in ms between swaps. |
pauseOnHover | boolean | false | Pause the cycle while the pointer is over the stack. |
onCardClick | (idx: number) => void | - | Called with the card index when a card is clicked. |
skewAmount | number | 6 | Degrees of skew applied to the stack. |
easing | "linear" | "elastic" | "elastic" | Easing preset for the swap animation. |
Card
Extends div. Each child of CardSwap should be a Card.
| Prop | Type | Default | Description |
|---|---|---|---|
customClass | string | - | Extra classes merged onto the card. |
className | string | - | Additional CSS classes for the card. |