A dark payment card with chip, number, holder and expiry, whose warm glow follows the pointer with smoothing and an optional 3D tilt, settling back to a soft rest glow when the pointer leaves.
Preview
Customize
10 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add credit-cardWrites components/ui/credit-card.tsx and components/styles/credit-card.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { CreditCard } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <CreditCard />6 );7}Props
Accessibility
Exposed as a single role="img" with a descriptive label (brand, last four digits, holder, expiry) so the decorative layout isn't read piece by piece; the full number is never announced. Pointer effects are visual only, and under prefers-reduced-motion the tilt is disabled and the glow jumps without easing.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/credit-card.module.css";5 6export interface CreditCardProps {7 /** Issuer / brand text in the top-left corner */8 brand?: string;9 /** Card number (spaces are kept as typed) */10 number?: string;11 /** Cardholder name */12 holder?: string;13 /** Expiry date */14 expiry?: string;15 /** Glow color that follows the pointer */16 glowColor?: string;17 /** EMV chip color */18 chipColor?: string;19 /** Card surface color */20 cardColor?: string;21 /** Tilt the card in 3D toward the pointer */22 tilt?: boolean;23 /** Mask all but the last four digits */24 maskNumber?: boolean;25 /** Card width in pixels (height follows the ISO card ratio) */26 width?: number;27 /** Optional className merged onto the root */28 className?: string;29 /** Optional inline styles merged onto the root */30 style?: React.CSSProperties;31}32 33const REST = { x: 0.5, y: 0.85 };34 35function masked(value: string): string {36 const digits = value.replace(/\D/g, "");37 const last = digits.slice(-4);38 return `•••• •••• •••• ${last}`;39}40 41export function CreditCard({42 brand = "MOTIONFOUNDRY",43 number = "4242 4242 4242 4242",44 holder = "ALEX MORGAN",45 expiry = "12/28",46 glowColor = "#ff8a1f",47 chipColor = "#9a5617",48 cardColor = "#1a1817",49 tilt = true,50 maskNumber = false,51 width = 380,52 className = "",53 style,54}: CreditCardProps) {55 const cardRef = React.useRef<HTMLDivElement>(null);56 const target = React.useRef({ ...REST, hover: 0 });57 const current = React.useRef({ ...REST, hover: 0 });58 const frame = React.useRef(0);59 const reduceMotion = React.useRef(false);60 61 React.useEffect(() => {62 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");63 reduceMotion.current = mq.matches;64 const onChange = () => (reduceMotion.current = mq.matches);65 mq.addEventListener("change", onChange);66 return () => {67 mq.removeEventListener("change", onChange);68 cancelAnimationFrame(frame.current);69 };70 }, []);71 72 // Ease the glow (and tilt) toward the pointer; stop the loop once it settles.73 const tick = React.useCallback(() => {74 const el = cardRef.current;75 if (!el) return;76 const t = target.current;77 const c = current.current;78 const k = reduceMotion.current ? 1 : 0.14;79 c.x += (t.x - c.x) * k;80 c.y += (t.y - c.y) * k;81 c.hover += (t.hover - c.hover) * k;82 el.style.setProperty("--mf-cc-x", `${(c.x * 100).toFixed(2)}%`);83 el.style.setProperty("--mf-cc-y", `${(c.y * 100).toFixed(2)}%`);84 el.style.setProperty("--mf-cc-hover", c.hover.toFixed(3));85 const tiltOn = tilt && !reduceMotion.current;86 el.style.setProperty("--mf-cc-rx", tiltOn ? `${((0.5 - c.y) * 14 * c.hover).toFixed(2)}deg` : "0deg");87 el.style.setProperty("--mf-cc-ry", tiltOn ? `${((c.x - 0.5) * 18 * c.hover).toFixed(2)}deg` : "0deg");88 const settled = Math.abs(t.x - c.x) + Math.abs(t.y - c.y) + Math.abs(t.hover - c.hover) < 0.001;89 frame.current = settled ? 0 : requestAnimationFrame(tick);90 }, [tilt]);91 92 const kick = () => {93 if (!frame.current) frame.current = requestAnimationFrame(tick);94 };95 96 const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {97 const rect = e.currentTarget.getBoundingClientRect();98 target.current = { x: (e.clientX - rect.left) / rect.width, y: (e.clientY - rect.top) / rect.height, hover: 1 };99 kick();100 };101 102 const onPointerLeave = () => {103 target.current = { ...REST, hover: 0 };104 kick();105 };106 107 const shown = maskNumber ? masked(number) : number;108 const last4 = number.replace(/\D/g, "").slice(-4);109 110 return (111 <div112 className={`${styles.root} ${className}`.trim()}113 style={114 {115 "--mf-cc-width": `${width}px`,116 "--mf-cc-glow": glowColor,117 "--mf-cc-chip": chipColor,118 "--mf-cc-card": cardColor,119 ...style,120 } as React.CSSProperties121 }122 >123 <div124 ref={cardRef}125 role="img"126 aria-label={`${brand} card ending in ${last4}, ${holder}, expires ${expiry}`}127 className={styles.card}128 onPointerMove={onPointerMove}129 onPointerLeave={onPointerLeave}130 >131 <span className={styles.glow} aria-hidden="true" />132 <span className={styles.sheen} aria-hidden="true" />133 <div className={styles.top} aria-hidden="true">134 <span className={styles.brand}>{brand}</span>135 <span className={styles.chip}>136 <span />137 <span />138 </span>139 </div>140 <div className={styles.number} aria-hidden="true">141 {shown}142 </div>143 <div className={styles.bottom} aria-hidden="true">144 <span>{holder}</span>145 <span>{expiry}</span>146 </div>147 </div>148 </div>149 );150}