cortexcn

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-swap
Install the following dependencies:
npm install gsap
Copy and paste the following code into your project.
components/ui/card-swap.tsx
"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

PropTypeDefaultDescription
widthnumber | string500Width of each card.
heightnumber | string400Height of each card.
cardDistancenumber60Horizontal offset between stacked cards.
verticalDistancenumber70Vertical offset between stacked cards.
delaynumber5000Time in ms between swaps.
pauseOnHoverbooleanfalsePause the cycle while the pointer is over the stack.
onCardClick(idx: number) => void-Called with the card index when a card is clicked.
skewAmountnumber6Degrees 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.

PropTypeDefaultDescription
customClassstring-Extra classes merged onto the card.
classNamestring-Additional CSS classes for the card.

On this page