"use client";
/**
* MetalCardStack, from https://motion-brew.com/components/metal-card-stack
*
* One self contained file: the component and the cn()
* helper. Needs React 18 or 19 and Tailwind CSS v4.
*/
import * as React from "react";
import { useGSAP } from "@gsap/react";
import { type ClassValue, clsx } from "clsx";
import gsap from "gsap";
import { useReducedMotion } from "motion/react";
import { twMerge } from "tailwind-merge";
// cn(): merges Tailwind classes, later ones win.
/** Merge Tailwind classes, letting later classes win conflicts. */
function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
// MetalCardStack
/**
* MetalCardStack
*
* Trigger: drag (plus hover and keys). A stack of metal payment cards that
* feel like the real thing: brushed metal with a fine grain, bevelled edges
* that catch the light, a visible card edge underneath, an embossed number
* and a light sheen that slides across the surface as the front card tilts
* toward the pointer. Grab the front card and it follows your hand, leaning
* into the drag. Throw it any way, left, right, up or down, and it flies off
* that way and tucks in at the back of the stack. Click the front card (or press Enter) and
* it turns over in 3D to show its back: the magnetic stripe, the signature
* panel with the security code and the small print; click again to turn it
* back. A short drag springs back. The rest of the
* stack slides up or down a step each time, smaller and dimmer the deeper it
* sits. The arrow keys do the same. Reduced motion swaps the throws for quick
* fades. On a narrow screen the whole stack scales down to fit (never up). On
* touch, the page still scrolls up and down over the card; a sideways swipe
* or flick throws it, and a gesture the browser takes for scrolling springs
* the card back.
*
* Engine: gsap + @gsap/react (quickTo for the drag and tilt, timelines for the
* throw, scoped with useGSAP).
*/
gsap.registerPlugin(useGSAP);
export interface MetalCard {
/** Name on the card. */
holder: string;
/** The full card number, digits only. Grouped the way its network prints it. */
number: string;
/** The card network, shown as its mark in the corner. */
network: "visa" | "mastercard" | "amex" | "discover";
/** The issuer's name, top left. */
bank?: string;
/** Security code shown on the back. Default `"123"`. */
cvv?: string;
/** Expiry, MM/YY. */
expiry: string;
/** The metal. */
finish: "graphite" | "gold" | "titanium" | "obsidian";
/** Small label in the corner, like the plan name. */
tier?: string;
}
export interface MetalCardStackProps {
/** Cards in the stack, front first. Default four. */
cards?: MetalCard[];
/** Card width in px. Height follows the real card ratio. Default `320`. */
width?: number;
/** Called with the card now at the front. */
onChange?: (card: MetalCard, index: number) => void;
className?: string;
}
const DEFAULT_CARDS: MetalCard[] = [
{
holder: "Alex Morgan",
number: "4821660422591937",
network: "visa",
bank: "Northwind",
tier: "Reserve",
expiry: "08/29",
finish: "graphite",
},
{
holder: "Alex Morgan",
number: "5412751234561937",
network: "mastercard",
bank: "Harbor",
tier: "Private",
expiry: "11/28",
finish: "gold",
},
{
holder: "Alex Morgan",
number: "378282246310005",
network: "amex",
bank: "Meridian",
tier: "Platinum",
expiry: "03/30",
finish: "titanium",
},
{
holder: "Alex Morgan",
number: "6011000990139424",
network: "discover",
bank: "Summit",
tier: "Infinite",
expiry: "06/27",
finish: "obsidian",
},
];
/** The number grouped as printed: 4 6 5 for Amex, fours for everyone else. */
const groups = (number: string, network: MetalCard["network"]) => {
const d = number.replace(/\D/g, "");
if (network === "amex")
return [d.slice(0, 4), d.slice(4, 10), d.slice(10, 15)].join(" ");
return d.replace(/(.{4})(?=.)/g, "$1 ");
};
/** Card network marks, drawn inline for the metal. */
function Network({
network,
ink,
}: {
network: MetalCard["network"];
ink: string;
}) {
const font =
"ui-sans-serif, system-ui, -apple-system, Helvetica, Arial, sans-serif";
if (network === "mastercard")
return (
);
if (network === "amex")
return (
);
if (network === "discover")
return (
);
return (
);
}
/** Base metal, brushed highlight and ink colour for each finish. */
const METALS = {
graphite: {
base: "linear-gradient(135deg, #5b6068 0%, #2f3338 38%, #4a4f56 62%, #23262a 100%)",
ink: "rgba(255,255,255,0.88)",
edge: "#17191c",
},
gold: {
base: "linear-gradient(135deg, #f3dca2 0%, #c9a35a 34%, #e9cf8c 58%, #a8823c 100%)",
ink: "rgba(58,40,10,0.85)",
edge: "#7a5a22",
},
titanium: {
base: "linear-gradient(135deg, #eef0f2 0%, #b9bec4 36%, #dfe2e6 60%, #9aa0a7 100%)",
ink: "rgba(30,34,40,0.82)",
edge: "#6f757c",
},
obsidian: {
base: "linear-gradient(135deg, #2a2b2f 0%, #0c0c0e 40%, #1d1e22 64%, #050506 100%)",
ink: "rgba(255,255,255,0.82)",
edge: "#000000",
},
} as const;
const RATIO = 1.586;
const VISIBLE = 4;
const THROW = 80;
/** The metal plate both sides share: finish, bevel, brushed grain and the moving sheen. */
function Plate({
card,
width,
children,
}: {
card: MetalCard;
width: number;
children: React.ReactNode;
}) {
const m = METALS[card.finish];
return (
{/* Brushed grain: fine horizontal strokes over the metal. */}
{/* The sheen: a soft light band whose position follows the card's tilt. */}
{children}
);
}
const embossFor = (card: MetalCard) =>
card.finish === "gold" || card.finish === "titanium"
? "0 1px 0 rgba(255,255,255,0.55), 0 -1px 0 rgba(0,0,0,0.25)"
: "0 1px 0 rgba(255,255,255,0.18), 0 -1px 0 rgba(0,0,0,0.6)";
/** Front: bank, chip and hologram, the big embossed number, expiry and the cardholder. */
function Front({ card, width }: { card: MetalCard; width: number }) {
const m = METALS[card.finish];
return (
{card.bank ?? "Bank"}
{card.tier}
{/* The chip: gold contacts with etched lines. */}
{/* The hologram: a silver patch whose rainbow shifts with the tilt. */}
{/* The big embossed number. */}
{groups(card.number, card.network)}
{card.number.slice(0, 4)}
Expires
End
{card.expiry}
{card.holder}
);
}
/** Back: service line, magnetic stripe, signature panel with the security code, and small print. */
function Back({ card, width }: { card: MetalCard; width: number }) {
return (
For customer service, call +1 800 555 0142
{card.cvv ?? "123"}
Use of this card is subject to the cardholder agreement. If found,
please return it to any {card.bank ?? "branch"}.
);
}
export const MetalCardStack = React.forwardRef<
HTMLDivElement,
MetalCardStackProps
>(function MetalCardStack(
{ cards = DEFAULT_CARDS, width = 320, onChange, className },
ref,
) {
const still = !!useReducedMotion();
const root = React.useRef(null);
React.useImperativeHandle(ref, () => root.current as HTMLDivElement);
const els = React.useRef<(HTMLDivElement | null)[]>([]);
// order[k] is the index of the card at depth k (0 is the front).
const order = React.useRef(cards.map((_, i) => i));
const busy = React.useRef(false);
const [front, setFront] = React.useState(0);
const [side, setSide] = React.useState<"front" | "back">("front");
const height = width / RATIO;
const n = cards.length;
// Fit narrow screens: the stack keeps its design and scales down as a whole
// (never up), measured from the room the root is given.
const [fit, setFit] = React.useState(1);
React.useLayoutEffect(() => {
const el = root.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => {
const room = el.clientWidth;
setFit(Math.min(1, Math.max(0.5, (room - 16) / width)));
});
ro.observe(el);
return () => ro.disconnect();
}, [width]);
const fitRef = React.useRef(fit);
fitRef.current = fit;
/** Where a card sits at a given depth in the stack. */
const slot = (depth: number) => {
const d = Math.min(depth, VISIBLE - 1);
return {
x: 0,
y: -d * 18,
// Real depth, so a tilting card never cuts through the one behind it.
z: -d * 48,
scale: 1 - d * 0.06,
rotation: 0,
rotationX: 0,
rotationY: 0,
autoAlpha: depth < VISIBLE ? 1 : 0,
zIndex: 100 - depth,
};
};
/** Deeper cards sit in shade: a dark overlay inside the card, not a CSS filter, which would flatten the flip. */
const shade = (el: HTMLElement, depth: number, duration: number) =>
gsap.to(el.querySelector("[data-shade]"), {
opacity: Math.min(depth, VISIBLE - 1) * 0.14,
duration,
ease: "power3.out",
});
const flips = React.useRef<(HTMLDivElement | null)[]>([]);
const flipped = React.useRef([]);
const { contextSafe } = useGSAP(
() => {
order.current.forEach((i, depth) => {
const el = els.current[i];
if (!el) return;
gsap.set(el, slot(depth));
shade(el, depth, 0);
});
},
{ scope: root, dependencies: [n, width] },
);
/** Slide every card into its slot for the current order. */
const settle = contextSafe((skip?: number) => {
order.current.forEach((i, depth) => {
const el = els.current[i];
if (!el || i === skip) return;
shade(el, depth, still ? 0 : 0.55);
gsap.to(el, {
...slot(depth),
duration: still ? 0 : 0.55,
ease: "power3.out",
overwrite: "auto",
});
});
});
const announce = () => {
const i = order.current[0]!;
setFront(i);
onChange?.(cards[i]!, i);
};
/** Front to back: the front card flies off the way it was thrown and tucks in behind. */
const sendBack = contextSafe((dir: "left" | "right" | "up" | "down") => {
if (busy.current || n < 2) return;
busy.current = true;
const i = order.current[0]!;
const el = els.current[i]!;
order.current = [...order.current.slice(1), i];
settle(i);
const out = {
left: { x: -width * 0.95, y: -10, z: 40, rotation: -14, rotationY: -18 },
right: { x: width * 0.95, y: -10, z: 40, rotation: 14, rotationY: 18 },
up: { x: 10, y: -height * 1.05, z: 40, rotation: 4, rotationX: 28 },
down: { x: -10, y: height * 1.05, z: 40, rotation: -4, rotationX: -28 },
}[dir];
const back = slot(order.current.length - 1);
unflip(i);
shade(el, order.current.length - 1, still ? 0 : 0.8);
gsap
.timeline({ onComplete: () => void ((busy.current = false), announce()) })
.to(el, { ...out, duration: still ? 0 : 0.32, ease: "power2.out" })
.set(el, { zIndex: back.zIndex })
.to(el, { ...back, duration: still ? 0 : 0.5, ease: "power3.out" });
});
/** Turn a card over to its back, or back to its face, with a small lift in the middle of the turn. */
const flip = contextSafe((i: number) => {
const f = flips.current[i];
if (!f || busy.current) return;
const to = !flipped.current[i];
flipped.current[i] = to;
setSide(to ? "back" : "front");
if (still) return void gsap.set(f, { rotationY: to ? 180 : 0 });
turn(f, to ? 180 : 0, 0.75, "power3.inOut");
});
const unflip = (i: number) => {
const f = flips.current[i];
if (!f || !flipped.current[i]) return;
flipped.current[i] = false;
setSide("front");
if (still) return void gsap.set(f, { rotationY: 0, z: 0 });
turn(f, 0, 0.5, "power3.out");
};
/**
* Turn a card about its vertical axis without it cutting through the card
* behind. Mid turn, its far half swings width / 2 back into the stack, so
* the card rises toward the viewer by just that much, following the angle,
* and sinks back as it lands flat.
*/
const turn = (
f: HTMLElement,
rotationY: number,
duration: number,
ease: string,
) => {
const reach = width / 2;
const gap = 48 - 20; // slot spacing, less a margin for the hover lean
gsap.to(f, {
rotationY,
duration,
ease,
overwrite: "auto",
onUpdate: () => {
const r =
(gsap.getProperty(f, "rotationY") as number) * (Math.PI / 180);
gsap.set(f, { z: Math.max(0, reach * Math.abs(Math.sin(r)) - gap) });
},
});
};
// Drag the front card: it follows the hand and leans into the move; a throw decides the rest.
const dragging = React.useRef<{
x: number;
y: number;
t: number;
vx: number;
vy: number;
touch: boolean;
} | null>(null);
const onDown = contextSafe((e: React.PointerEvent) => {
if (busy.current || e.button !== 0) return;
dragging.current = {
x: e.clientX,
y: e.clientY,
t: performance.now(),
vx: 0,
vy: 0,
touch: e.pointerType !== "mouse",
};
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
});
const onMove = contextSafe((e: React.PointerEvent) => {
const el = els.current[order.current[0]!];
if (!el) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width;
const py = (e.clientY - r.top) / r.height;
// Tilt toward the pointer and slide the sheen, even when not dragging.
el.style.setProperty(
"--sheen-x",
`${Math.max(0, Math.min(100, px * 100))}%`,
);
el.style.setProperty(
"--sheen-y",
`${Math.max(0, Math.min(100, py * 100))}%`,
);
const g = dragging.current;
if (!g) {
// The tilt follows a mouse; a finger only moves the card while it drags.
if (still || busy.current || e.pointerType !== "mouse") return;
gsap.to(el, {
rotationY: (px - 0.5) * 14,
rotationX: -(py - 0.5) * 12,
duration: 0.5,
ease: "power2.out",
overwrite: "auto",
});
return;
}
// Screen pixels to the stack's own, so the card stays under the finger when scaled.
const k = fitRef.current;
const dx = (e.clientX - g.x) / k;
// On touch the page owns vertical moves; the card only travels sideways.
const dy = g.touch ? 0 : (e.clientY - g.y) / k;
const now = performance.now();
const dt = Math.max(1, now - g.t);
g.vx = (dx / dt) * 16;
g.vy = (dy / dt) * 16;
gsap.to(el, {
x: dx,
y: dy,
rotation: dx * 0.04,
rotationY: dx * 0.06,
rotationX: -dy * 0.06,
duration: 0.15,
ease: "power2.out",
overwrite: "auto",
});
});
const onUp = contextSafe((e: React.PointerEvent) => {
const g = dragging.current;
dragging.current = null;
if (!g) return;
const k = fitRef.current;
const dx = (e.clientX - g.x) / k;
const dy = g.touch ? 0 : (e.clientY - g.y) / k;
const horizontal = Math.abs(dx) >= Math.abs(dy);
// A thumb swipe is shorter than a mouse drag, and a flick counts too.
const reach = g.touch ? THROW * 0.6 : THROW;
const flick = g.touch ? 8 : 12;
const far = horizontal
? Math.abs(dx) > reach || Math.abs(g.vx) > flick
: Math.abs(dy) > reach * 0.8 || Math.abs(g.vy) > flick;
if (!far) {
settle();
// A press without a drag turns the front card over (a finger wobbles a little).
if (Math.hypot(e.clientX - g.x, e.clientY - g.y) < (g.touch ? 10 : 6))
flip(order.current[0]!);
return;
}
if (horizontal) sendBack(dx < 0 ? "left" : "right");
else sendBack(dy < 0 ? "up" : "down");
});
// The browser took the gesture (a page scroll): the card goes home, nothing is thrown.
const onCancel = contextSafe(() => {
if (!dragging.current) return;
dragging.current = null;
settle();
});
const onLeave = contextSafe(() => {
if (dragging.current || busy.current) return;
const el = els.current[order.current[0]!];
if (el)
gsap.to(el, {
rotationX: 0,
rotationY: 0,
duration: 0.6,
ease: "power2.out",
});
});
return (
{
if (e.key === "Enter" || e.key === " ")
(e.preventDefault(), flip(order.current[0]!));
else if (e.key === "ArrowLeft") (e.preventDefault(), sendBack("left"));
else if (e.key === "ArrowUp") (e.preventDefault(), sendBack("up"));
else if (e.key === "ArrowRight")
(e.preventDefault(), sendBack("right"));
else if (e.key === "ArrowDown") (e.preventDefault(), sendBack("down"));
}}
className={cn(
"relative grid select-none place-items-center outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-4",
className,
)}
style={{
width: width + 120,
maxWidth: "100%",
height: (height + 140) * fit,
perspective: 1100,
}}
>
{/* Scales the whole stack to fit, keeping its 3D. Negative margins
shrink its layout box to the scaled size, so it stays centred. */}
{cards.map((card, i) => (
void (els.current[i] = el)}
aria-hidden={i !== front}
className="absolute left-0 top-0 will-change-transform"
style={{ transformStyle: "preserve-3d" }}
>
{/* The flipper holds both faces back to back; the edge slab rides along under them. */}