Card Folder
A landscape card tucked into a stitched purse pocket that lifts forward as the purse compresses into its bottom seam, with controlled open and card-detail visibility plus a separate overflow action.
Preview
Expiry08/29CVV•••Saurabh Chauhan•••• •••• •••• 0806
TSXcomponents/previews/blocks/card-folder.preview.tsx
"use client";
import { useState } from "react";
import { CardFolder } from "@/components/motion/card-folder";
import { DigitSwap } from "@/components/motion/digit-swap";
const CARD_NUMBER = "3745 6987 4096 0806";
const MASKED_CARD_NUMBER = "•••• •••• •••• 0806";
const CONTOUR_RADII = Array.from({ length: 18 }, (_, index) => 72 + index * 24);
function CardArtwork({ detailsVisible }: { detailsVisible: boolean }) {
return (
<span className="relative block h-full w-full overflow-hidden bg-[linear-gradient(135deg,#383a38_0%,#202120_48%,#111211_100%)] text-white">
<svg
aria-hidden="true"
viewBox="0 0 640 404"
className="absolute inset-0 h-full w-full"
>
{CONTOUR_RADII.map((radius, index) => (
<circle
// The concentric contours deliberately begin outside the card so
// their cropped curves read like the reference artwork.
key={radius}
cx="-20"
cy="-28"
r={radius}
fill="none"
stroke="currentColor"
strokeOpacity={0.32 - index * 0.009}
strokeWidth="1.5"
/>
))}
</svg>
<span className="absolute inset-0 bg-[radial-gradient(circle_at_78%_8%,rgb(255_255_255/0.11),transparent_32%)]" />
<span className="absolute left-[6%] top-[8%] flex flex-col gap-2">
<span className="text-[10px] font-medium uppercase tracking-[0.22em] text-white/55">
Saurabh Chauhan
</span>
<DigitSwap
value={detailsVisible ? CARD_NUMBER : MASKED_CARD_NUMBER}
animationKey={detailsVisible ? "revealed" : "masked"}
direction={detailsVisible ? "up" : "down"}
suffixLength={4}
glyphClassName={detailsVisible ? "text-white/90" : "text-white/65"}
suffixClassName="text-white/90"
className="font-mono text-xs tracking-[0.13em]"
/>
</span>
<span className="absolute top-[8%] right-[7%] h-[20%] w-[13%] overflow-hidden rounded-[18%] border border-black/30 bg-[linear-gradient(135deg,#f2f0ea_0%,#b9b7b1_45%,#e4e1d9_100%)]">
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-black/30" />
<span className="absolute inset-x-0 top-1/3 h-px bg-black/30" />
<span className="absolute inset-x-0 bottom-1/3 h-px bg-black/30" />
<span className="absolute left-0 top-1/2 h-[34%] w-[28%] -translate-y-1/2 rounded-r-[35%] border border-l-0 border-black/30" />
<span className="absolute right-0 top-1/2 h-[34%] w-[28%] -translate-y-1/2 rounded-l-[35%] border border-r-0 border-black/30" />
</span>
<span className="absolute bottom-[9%] left-[6%] flex items-center gap-2 text-lg font-semibold tracking-[-0.04em]">
beUI <span className="block size-2.5 rotate-45 rounded-[2px] bg-white/85" />
</span>
<span className="absolute right-[7%] bottom-[10%] flex items-end gap-5">
<span className="flex flex-col gap-1">
<span className="text-[8px] font-medium uppercase tracking-[0.16em] text-white/40">
Expiry
</span>
<span className="text-xs font-medium text-white/90 tabular-nums">
08/29
</span>
</span>
<span className="flex flex-col gap-1">
<span className="text-[8px] font-medium uppercase tracking-[0.16em] text-white/40">
CVV
</span>
<DigitSwap
value={detailsVisible ? "123" : "•••"}
animationKey={detailsVisible ? "revealed" : "masked"}
direction={detailsVisible ? "up" : "down"}
className="font-mono text-xs font-medium text-white/90"
/>
</span>
</span>
</span>
);
}
export function CardFolderPreview() {
const [detailsVisible, setDetailsVisible] = useState(false);
return (
<div className="flex min-h-72 w-full items-center justify-center px-6 py-9">
<CardFolder
title="Saurabh Chauhan"
cardNumber={CARD_NUMBER}
expiry="08/29"
cvv="123"
detailsVisible={detailsVisible}
onDetailsVisibleChange={setDetailsVisible}
card={<CardArtwork detailsVisible={detailsVisible} />}
/>
</div>
);
}
TSXcomponents/motion/card-folder.tsx
"use client";
// beui.dev/components/blocks/card-folder
import { EllipsisVertical, Eye, EyeOff } from "lucide-react";
import {
AnimatePresence,
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { DigitSwap } from "@/components/motion/digit-swap";
import {
EASE_IN_OUT,
EASE_OUT,
SPRING_LAYOUT,
SPRING_PRESS,
} from "@/lib/ease";
import { cn } from "@/lib/utils";
// The two purse layers collapse as one material surface after the card starts
// moving, then reverse immediately so closing feels like the card is caught.
const PURSE_MORPH_TRANSITION = {
duration: 0.28,
ease: EASE_IN_OUT,
} as const;
const PURSE_REDUCED_TRANSITION = {
duration: 0.16,
ease: EASE_OUT,
} as const;
export interface CardFolderProps {
title: string;
cardNumber: string;
expiry: string;
cvv: string;
card: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
detailsVisible?: boolean;
defaultDetailsVisible?: boolean;
onDetailsVisibleChange?: (visible: boolean) => void;
onClick?: () => void;
onAction?: () => void;
ariaLabel?: string;
actionLabel?: string;
disabled?: boolean;
className?: string;
cardClassName?: string;
}
/**
* A landscape card tucked into an animated folder sleeve. Pressing the folder
* lifts the card forward while the purse compresses into its bottom seam; a
* separate privacy control reveals its number and CVV.
*/
export function CardFolder({
title,
cardNumber,
expiry,
cvv,
card,
open,
defaultOpen = false,
onOpenChange,
detailsVisible,
defaultDetailsVisible = false,
onDetailsVisibleChange,
onClick,
onAction,
ariaLabel,
actionLabel,
disabled = false,
className,
cardClassName,
}: CardFolderProps) {
const reduce = useReducedMotion();
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [internalDetailsVisible, setInternalDetailsVisible] = useState(
defaultDetailsVisible,
);
const openControlled = open !== undefined;
const detailsControlled = detailsVisible !== undefined;
const isOpen = open ?? internalOpen;
const areDetailsVisible = detailsVisible ?? internalDetailsVisible;
const transition = reduce ? { duration: 0 } : SPRING_LAYOUT;
const normalizedCardNumber = cardNumber.replace(/\D/g, "");
const visibleLastFour = normalizedCardNumber.slice(-4).padStart(4, "•");
const revealedCardNumber =
normalizedCardNumber.match(/.{1,4}/g)?.join(" ") ?? visibleLastFour;
const maskedCvv = "•".repeat(Math.max(3, cvv.length));
const defaultAriaLabel = `${isOpen ? "Close" : "Open"} ${title}, card ending in ${visibleLastFour}, expires ${expiry}`;
const progress = useMotionValue(isOpen ? 1 : 0);
const cardTransform = useTransform(progress, (value) => {
const boundedProgress = Math.min(1, Math.max(0, value));
const lift = Math.sin(Math.PI * boundedProgress);
return `translateY(${-8 * lift}%) scale(${1 + 0.01 * lift})`;
});
const backTransform = useTransform(
progress,
[0, 1],
["translateY(0%) scaleY(1)", "translateY(18%) scaleY(0.18)"],
);
const frontTransform = useTransform(
progress,
[0, 1],
["translateY(0%) rotateX(0deg)", "translateY(18%) rotateX(-72deg)"],
);
const purseOpacity = useTransform(progress, [0, 0.76, 1], [1, 1, 0]);
useEffect(() => {
const controls = animate(
progress,
isOpen ? 1 : 0,
reduce ? { duration: 0 } : PURSE_MORPH_TRANSITION,
);
return () => controls.stop();
}, [isOpen, progress, reduce]);
const setOpen = useCallback(
(nextOpen: boolean) => {
if (disabled) return;
if (!openControlled) setInternalOpen(nextOpen);
onOpenChange?.(nextOpen);
},
[disabled, onOpenChange, openControlled],
);
const handleClick = () => {
setOpen(!isOpen);
onClick?.();
};
const toggleDetails = () => {
if (disabled) return;
const nextVisible = !areDetailsVisible;
if (!detailsControlled) setInternalDetailsVisible(nextVisible);
onDetailsVisibleChange?.(nextVisible);
};
return (
<div
data-open={isOpen ? "true" : "false"}
data-details-visible={areDetailsVisible ? "true" : "false"}
className={cn(
"relative aspect-[1029/592] w-96 max-w-full select-none [perspective:1200px]",
className,
)}
>
<motion.button
type="button"
disabled={disabled}
aria-label={ariaLabel ?? defaultAriaLabel}
aria-expanded={isOpen}
onClick={handleClick}
whileTap={reduce || disabled ? undefined : { scale: 0.96 }}
transition={reduce ? { duration: 0 } : SPRING_PRESS}
className="absolute inset-0 block rounded-2xl text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
>
<motion.span
data-slot="card-folder-back"
aria-hidden="true"
initial={false}
animate={reduce ? { opacity: isOpen ? 0 : 1 } : undefined}
transition={PURSE_REDUCED_TRANSITION}
style={
reduce
? undefined
: { opacity: purseOpacity, transform: backTransform }
}
className="absolute inset-x-0 bottom-0 top-[10%] rounded-2xl border border-foreground/10 bg-background [transform-origin:center_bottom]"
/>
<motion.span
aria-hidden="true"
initial={false}
animate={
reduce ? { transform: "translateY(0%) scale(1)" } : undefined
}
style={reduce ? undefined : { transform: cardTransform }}
className={cn(
"absolute left-[4.7%] right-[4.7%] top-0 z-10 aspect-[1.586/1] overflow-hidden rounded-xl border border-foreground/10 bg-background [transform-origin:center_bottom] will-change-transform",
cardClassName,
)}
>
{card}
</motion.span>
</motion.button>
<motion.span
aria-hidden={isOpen}
inert={isOpen}
initial={false}
animate={reduce ? { opacity: isOpen ? 0 : 1 } : undefined}
transition={PURSE_REDUCED_TRANSITION}
style={
reduce
? undefined
: { opacity: purseOpacity, transform: frontTransform }
}
className="pointer-events-none absolute inset-x-0 bottom-0 top-1/2 z-20 [backface-visibility:hidden] [transform-origin:center_bottom]"
>
<svg
aria-hidden="true"
viewBox="0 0 384 110"
preserveAspectRatio="none"
className="absolute inset-0 size-full overflow-visible"
>
<path
d="M0 17C15 7 31 4 49 4H87C110 4 126 17 144 32L158 44C176 59 206 59 225 43L240 30C257 16 271 4 295 4H335C354 4 370 8 384 18V94C384 103 377 110 368 110H16C7 110 0 103 0 94Z"
fill="var(--background)"
stroke="var(--foreground)"
strokeOpacity="0.12"
vectorEffect="non-scaling-stroke"
/>
<path
d="M10 21C22 13 35 11 51 11H85C105 11 120 23 137 37L153 50C175 68 208 68 231 49L246 36C262 23 275 11 297 11H333C350 11 363 14 374 22V89C374 97 369 101 360 101H24C15 101 10 96 10 89Z"
fill="none"
stroke="var(--foreground)"
strokeDasharray="5 5"
strokeLinecap="round"
strokeOpacity="0.22"
vectorEffect="non-scaling-stroke"
/>
</svg>
<span className="absolute inset-x-[5%] inset-y-0 z-10 flex min-w-0 flex-col justify-between py-4">
<span className="flex items-start justify-between pr-2">
<motion.button
key="card-details-visibility"
type="button"
disabled={disabled}
tabIndex={isOpen ? -1 : undefined}
aria-label={
areDetailsVisible
? "Hide card details"
: "Show card details"
}
aria-pressed={areDetailsVisible}
onClick={toggleDetails}
whileTap={reduce || disabled ? undefined : { scale: 0.96 }}
transition={reduce ? { duration: 0.12 } : SPRING_PRESS}
className={cn(
"z-30 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
isOpen ? "pointer-events-none" : "pointer-events-auto",
)}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={areDetailsVisible ? "hide" : "show"}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.25, filter: "blur(4px)" }
}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.25, filter: "blur(4px)" }
}
transition={
reduce
? { duration: 0.12 }
: { type: "spring", duration: 0.3, bounce: 0 }
}
className="flex items-center justify-center"
>
{areDetailsVisible ? (
<EyeOff className="size-4" aria-hidden="true" />
) : (
<Eye className="size-4" aria-hidden="true" />
)}
</motion.span>
</AnimatePresence>
</motion.button>
<span className="flex shrink-0 items-end gap-3.5 pt-2">
<span className="flex flex-col gap-0.5">
<span className="text-[9px] font-medium uppercase tracking-[0.12em] text-muted-foreground/65">
Expiry
</span>
<span className="text-xs font-medium text-foreground tabular-nums">
{expiry}
</span>
</span>
<span className="flex flex-col gap-0.5">
<span className="text-[9px] font-medium uppercase tracking-[0.12em] text-muted-foreground/65">
CVV
</span>
<DigitSwap
value={areDetailsVisible ? cvv : maskedCvv}
animationKey={
areDetailsVisible ? "revealed" : "masked"
}
direction={areDetailsVisible ? "up" : "down"}
className="text-xs font-medium text-foreground tabular-nums"
/>
</span>
</span>
</span>
<span className="flex min-w-0 items-baseline justify-between gap-4">
<span className="truncate text-lg font-medium leading-tight text-foreground">
{title}
</span>
<DigitSwap
value={
areDetailsVisible
? revealedCardNumber
: `•••• •••• •••• ${visibleLastFour}`
}
animationKey={areDetailsVisible ? "revealed" : "masked"}
direction={areDetailsVisible ? "up" : "down"}
suffixLength={4}
glyphClassName={
areDetailsVisible
? "text-foreground"
: "text-muted-foreground"
}
suffixClassName="text-foreground"
className="truncate font-mono text-xs tracking-[0.08em] tabular-nums"
/>
</span>
</span>
</motion.span>
{onAction ? (
<motion.button
type="button"
disabled={disabled}
aria-label={actionLabel ?? `Open actions for ${title}`}
onClick={onAction}
animate={{ y: isOpen && !reduce ? -14 : 0 }}
whileTap={reduce || disabled ? undefined : { scale: 0.96 }}
transition={transition}
className="absolute right-[2.6%] top-[10%] z-10 flex size-10 -translate-y-1/2 items-center justify-center rounded-full text-white/65 outline-none transition-colors hover:bg-white/10 hover:text-white focus-visible:ring-2 focus-visible:ring-white disabled:cursor-not-allowed disabled:opacity-50"
>
<EllipsisVertical className="size-4" aria-hidden="true" />
</motion.button>
) : null}
</div>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/card-folder
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/card-folder.tsx
"use client";
// beui.dev/components/blocks/card-folder
import { EllipsisVertical, Eye, EyeOff } from "lucide-react";
import {
AnimatePresence,
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { DigitSwap } from "@/components/motion/digit-swap";
import {
EASE_IN_OUT,
EASE_OUT,
SPRING_LAYOUT,
SPRING_PRESS,
} from "@/lib/ease";
import { cn } from "@/lib/utils";
// The two purse layers collapse as one material surface after the card starts
// moving, then reverse immediately so closing feels like the card is caught.
const PURSE_MORPH_TRANSITION = {
duration: 0.28,
ease: EASE_IN_OUT,
} as const;
const PURSE_REDUCED_TRANSITION = {
duration: 0.16,
ease: EASE_OUT,
} as const;
export interface CardFolderProps {
title: string;
cardNumber: string;
expiry: string;
cvv: string;
card: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
detailsVisible?: boolean;
defaultDetailsVisible?: boolean;
onDetailsVisibleChange?: (visible: boolean) => void;
onClick?: () => void;
onAction?: () => void;
ariaLabel?: string;
actionLabel?: string;
disabled?: boolean;
className?: string;
cardClassName?: string;
}
/**
* A landscape card tucked into an animated folder sleeve. Pressing the folder
* lifts the card forward while the purse compresses into its bottom seam; a
* separate privacy control reveals its number and CVV.
*/
export function CardFolder({
title,
cardNumber,
expiry,
cvv,
card,
open,
defaultOpen = false,
onOpenChange,
detailsVisible,
defaultDetailsVisible = false,
onDetailsVisibleChange,
onClick,
onAction,
ariaLabel,
actionLabel,
disabled = false,
className,
cardClassName,
}: CardFolderProps) {
const reduce = useReducedMotion();
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [internalDetailsVisible, setInternalDetailsVisible] = useState(
defaultDetailsVisible,
);
const openControlled = open !== undefined;
const detailsControlled = detailsVisible !== undefined;
const isOpen = open ?? internalOpen;
const areDetailsVisible = detailsVisible ?? internalDetailsVisible;
const transition = reduce ? { duration: 0 } : SPRING_LAYOUT;
const normalizedCardNumber = cardNumber.replace(/\D/g, "");
const visibleLastFour = normalizedCardNumber.slice(-4).padStart(4, "•");
const revealedCardNumber =
normalizedCardNumber.match(/.{1,4}/g)?.join(" ") ?? visibleLastFour;
const maskedCvv = "•".repeat(Math.max(3, cvv.length));
const defaultAriaLabel = `${isOpen ? "Close" : "Open"} ${title}, card ending in ${visibleLastFour}, expires ${expiry}`;
const progress = useMotionValue(isOpen ? 1 : 0);
const cardTransform = useTransform(progress, (value) => {
const boundedProgress = Math.min(1, Math.max(0, value));
const lift = Math.sin(Math.PI * boundedProgress);
return `translateY(${-8 * lift}%) scale(${1 + 0.01 * lift})`;
});
const backTransform = useTransform(
progress,
[0, 1],
["translateY(0%) scaleY(1)", "translateY(18%) scaleY(0.18)"],
);
const frontTransform = useTransform(
progress,
[0, 1],
["translateY(0%) rotateX(0deg)", "translateY(18%) rotateX(-72deg)"],
);
const purseOpacity = useTransform(progress, [0, 0.76, 1], [1, 1, 0]);
useEffect(() => {
const controls = animate(
progress,
isOpen ? 1 : 0,
reduce ? { duration: 0 } : PURSE_MORPH_TRANSITION,
);
return () => controls.stop();
}, [isOpen, progress, reduce]);
const setOpen = useCallback(
(nextOpen: boolean) => {
if (disabled) return;
if (!openControlled) setInternalOpen(nextOpen);
onOpenChange?.(nextOpen);
},
[disabled, onOpenChange, openControlled],
);
const handleClick = () => {
setOpen(!isOpen);
onClick?.();
};
const toggleDetails = () => {
if (disabled) return;
const nextVisible = !areDetailsVisible;
if (!detailsControlled) setInternalDetailsVisible(nextVisible);
onDetailsVisibleChange?.(nextVisible);
};
return (
<div
data-open={isOpen ? "true" : "false"}
data-details-visible={areDetailsVisible ? "true" : "false"}
className={cn(
"relative aspect-[1029/592] w-96 max-w-full select-none [perspective:1200px]",
className,
)}
>
<motion.button
type="button"
disabled={disabled}
aria-label={ariaLabel ?? defaultAriaLabel}
aria-expanded={isOpen}
onClick={handleClick}
whileTap={reduce || disabled ? undefined : { scale: 0.96 }}
transition={reduce ? { duration: 0 } : SPRING_PRESS}
className="absolute inset-0 block rounded-2xl text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
>
<motion.span
data-slot="card-folder-back"
aria-hidden="true"
initial={false}
animate={reduce ? { opacity: isOpen ? 0 : 1 } : undefined}
transition={PURSE_REDUCED_TRANSITION}
style={
reduce
? undefined
: { opacity: purseOpacity, transform: backTransform }
}
className="absolute inset-x-0 bottom-0 top-[10%] rounded-2xl border border-foreground/10 bg-background [transform-origin:center_bottom]"
/>
<motion.span
aria-hidden="true"
initial={false}
animate={
reduce ? { transform: "translateY(0%) scale(1)" } : undefined
}
style={reduce ? undefined : { transform: cardTransform }}
className={cn(
"absolute left-[4.7%] right-[4.7%] top-0 z-10 aspect-[1.586/1] overflow-hidden rounded-xl border border-foreground/10 bg-background [transform-origin:center_bottom] will-change-transform",
cardClassName,
)}
>
{card}
</motion.span>
</motion.button>
<motion.span
aria-hidden={isOpen}
inert={isOpen}
initial={false}
animate={reduce ? { opacity: isOpen ? 0 : 1 } : undefined}
transition={PURSE_REDUCED_TRANSITION}
style={
reduce
? undefined
: { opacity: purseOpacity, transform: frontTransform }
}
className="pointer-events-none absolute inset-x-0 bottom-0 top-1/2 z-20 [backface-visibility:hidden] [transform-origin:center_bottom]"
>
<svg
aria-hidden="true"
viewBox="0 0 384 110"
preserveAspectRatio="none"
className="absolute inset-0 size-full overflow-visible"
>
<path
d="M0 17C15 7 31 4 49 4H87C110 4 126 17 144 32L158 44C176 59 206 59 225 43L240 30C257 16 271 4 295 4H335C354 4 370 8 384 18V94C384 103 377 110 368 110H16C7 110 0 103 0 94Z"
fill="var(--background)"
stroke="var(--foreground)"
strokeOpacity="0.12"
vectorEffect="non-scaling-stroke"
/>
<path
d="M10 21C22 13 35 11 51 11H85C105 11 120 23 137 37L153 50C175 68 208 68 231 49L246 36C262 23 275 11 297 11H333C350 11 363 14 374 22V89C374 97 369 101 360 101H24C15 101 10 96 10 89Z"
fill="none"
stroke="var(--foreground)"
strokeDasharray="5 5"
strokeLinecap="round"
strokeOpacity="0.22"
vectorEffect="non-scaling-stroke"
/>
</svg>
<span className="absolute inset-x-[5%] inset-y-0 z-10 flex min-w-0 flex-col justify-between py-4">
<span className="flex items-start justify-between pr-2">
<motion.button
key="card-details-visibility"
type="button"
disabled={disabled}
tabIndex={isOpen ? -1 : undefined}
aria-label={
areDetailsVisible
? "Hide card details"
: "Show card details"
}
aria-pressed={areDetailsVisible}
onClick={toggleDetails}
whileTap={reduce || disabled ? undefined : { scale: 0.96 }}
transition={reduce ? { duration: 0.12 } : SPRING_PRESS}
className={cn(
"z-30 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
isOpen ? "pointer-events-none" : "pointer-events-auto",
)}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={areDetailsVisible ? "hide" : "show"}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.25, filter: "blur(4px)" }
}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.25, filter: "blur(4px)" }
}
transition={
reduce
? { duration: 0.12 }
: { type: "spring", duration: 0.3, bounce: 0 }
}
className="flex items-center justify-center"
>
{areDetailsVisible ? (
<EyeOff className="size-4" aria-hidden="true" />
) : (
<Eye className="size-4" aria-hidden="true" />
)}
</motion.span>
</AnimatePresence>
</motion.button>
<span className="flex shrink-0 items-end gap-3.5 pt-2">
<span className="flex flex-col gap-0.5">
<span className="text-[9px] font-medium uppercase tracking-[0.12em] text-muted-foreground/65">
Expiry
</span>
<span className="text-xs font-medium text-foreground tabular-nums">
{expiry}
</span>
</span>
<span className="flex flex-col gap-0.5">
<span className="text-[9px] font-medium uppercase tracking-[0.12em] text-muted-foreground/65">
CVV
</span>
<DigitSwap
value={areDetailsVisible ? cvv : maskedCvv}
animationKey={
areDetailsVisible ? "revealed" : "masked"
}
direction={areDetailsVisible ? "up" : "down"}
className="text-xs font-medium text-foreground tabular-nums"
/>
</span>
</span>
</span>
<span className="flex min-w-0 items-baseline justify-between gap-4">
<span className="truncate text-lg font-medium leading-tight text-foreground">
{title}
</span>
<DigitSwap
value={
areDetailsVisible
? revealedCardNumber
: `•••• •••• •••• ${visibleLastFour}`
}
animationKey={areDetailsVisible ? "revealed" : "masked"}
direction={areDetailsVisible ? "up" : "down"}
suffixLength={4}
glyphClassName={
areDetailsVisible
? "text-foreground"
: "text-muted-foreground"
}
suffixClassName="text-foreground"
className="truncate font-mono text-xs tracking-[0.08em] tabular-nums"
/>
</span>
</span>
</motion.span>
{onAction ? (
<motion.button
type="button"
disabled={disabled}
aria-label={actionLabel ?? `Open actions for ${title}`}
onClick={onAction}
animate={{ y: isOpen && !reduce ? -14 : 0 }}
whileTap={reduce || disabled ? undefined : { scale: 0.96 }}
transition={transition}
className="absolute right-[2.6%] top-[10%] z-10 flex size-10 -translate-y-1/2 items-center justify-center rounded-full text-white/65 outline-none transition-colors hover:bg-white/10 hover:text-white focus-visible:ring-2 focus-visible:ring-white disabled:cursor-not-allowed disabled:opacity-50"
>
<EllipsisVertical className="size-4" aria-hidden="true" />
</motion.button>
) : null}
</div>
);
}
TSXcomponents/motion/digit-swap.tsx
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type DigitSwapDirection = "up" | "down";
export interface DigitSwapProps {
/** Numeric or masked value rendered in fixed character slots. */
value: string | number;
/** Replays every glyph when the value itself contains unchanged characters. */
animationKey?: string | number;
/** Direction the next glyph enters from. */
direction?: DigitSwapDirection;
/** Per-glyph transition duration in seconds. */
duration?: number;
/** Delay in seconds between neighboring glyphs. */
stagger?: number;
/** Number of final characters that receive `suffixClassName`. */
suffixLength?: number;
className?: string;
glyphClassName?: string;
suffixClassName?: string;
}
type GlyphMotionContext = {
direction: DigitSwapDirection;
reduceMotion: boolean;
};
const GLYPH_VARIANTS = {
enter: ({ direction, reduceMotion }: GlyphMotionContext) => ({
opacity: 0,
transform: reduceMotion
? "none"
: `translateY(${direction === "up" ? "45%" : "-45%"})`,
}),
visible: {
opacity: 1,
transform: "translateY(0%)",
},
exit: ({ direction, reduceMotion }: GlyphMotionContext) => ({
opacity: 0,
transform: reduceMotion
? "none"
: `translateY(${direction === "up" ? "-45%" : "45%"})`,
}),
};
/** Fixed-slot digits and mask glyphs that roll when their value changes. */
export function DigitSwap({
value,
animationKey,
direction = "up",
duration = 0.18,
stagger = 0.006,
suffixLength = 0,
className,
glyphClassName,
suffixClassName,
}: DigitSwapProps) {
const reduceMotion = useReducedMotion() ?? false;
const text = String(value);
const suffixStart = Math.max(0, text.length - Math.max(0, suffixLength));
const motionContext: GlyphMotionContext = { direction, reduceMotion };
const glyphs = Array.from(text, (character, position) => ({
character,
id: `glyph-${position}`,
position,
}));
return (
<span
data-slot="digit-swap"
data-direction={direction}
className={cn("inline-flex items-center whitespace-nowrap", className)}
>
<span className="sr-only">{text}</span>
<span aria-hidden="true" className="inline-flex items-center">
{glyphs.map(({ character, id, position }) => {
if (character === " ") {
return <span key={id} className="inline-block w-[0.7ch]" />;
}
const glyphKey =
animationKey === undefined
? `${id}-${character}`
: `${id}-${character}-${animationKey}`;
return (
<span
key={id}
data-slot="digit-swap-glyph"
className="relative inline-block h-[1.1em] w-[1ch] shrink-0 overflow-hidden align-bottom"
>
<AnimatePresence initial={false} custom={motionContext}>
<motion.span
key={glyphKey}
custom={motionContext}
variants={GLYPH_VARIANTS}
initial="enter"
animate="visible"
exit="exit"
transition={{
duration: reduceMotion ? Math.min(duration, 0.12) : duration,
delay: reduceMotion ? 0 : position * stagger,
ease: EASE_OUT,
}}
className={cn(
"absolute inset-0 flex items-center justify-center leading-none",
glyphClassName,
position >= suffixStart ? suffixClassName : undefined,
)}
>
{character}
</motion.span>
</AnimatePresence>
</span>
);
})}
</span>
</span>
);
}
API Reference
titlestring—cardNumberstring—expirystring—cvvstring—cardReactNode—open?boolean—defaultOpen?booleanfalseonOpenChange?((open: boolean) => void)—detailsVisible?boolean—defaultDetailsVisible?booleanfalseonDetailsVisibleChange?((visible: boolean) => void)—onClick?(() => void)—onAction?(() => void)—ariaLabel?string—actionLabel?string—disabled?booleanfalseclassName?string—cardClassName?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated