Velocity UI
Loading…
Menu

Interactive Payment Card

3D flipping payment card with metallic chip and simple card type indication. Light/Dark ready.

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 add interactive-payment-card

Source Code

interactive-payment-card.tsx
 'use client'
 
 import React, { useMemo, useRef, useState } from 'react'
 import { motion, useMotionValue, useSpring, useTransform, AnimatePresence } from 'framer-motion'
 import confetti from 'canvas-confetti'
 
 function detectCardType(num: string): string {
   const n = num.replace(/\s+/g, '')
   if (n.startsWith('4')) return 'VISA'
   if (/^5[1-5]/.test(n)) return 'MASTERCARD'
   if (/^3[47]/.test(n)) return 'AMEX'
   return 'VELOCITY'
 }
 
 export default function InteractivePaymentCard() {
  const [number, setNumber] = useState('')
  const [name, setName] = useState('')
  const [expiry, setExpiry] = useState('')
  const [cvv, setCvv] = useState('')
  const [flipped, setFlipped] = useState(false)
  const [success, setSuccess] = useState<{ open: boolean; msg: string }>({ open: false, msg: '' })
  const type = useMemo(() => detectCardType(number), [number])
  const containerRef = useRef<HTMLDivElement>(null)
  const mx = useMotionValue(0)
  const my = useMotionValue(0)
  const sx = useSpring(mx, { stiffness: 120, damping: 18 })
  const sy = useSpring(my, { stiffness: 120, damping: 18 })
  const rotateY = useTransform(sx, (v) => (v / 60))
  const rotateX = useTransform(sy, (v) => -(v / 60))
  const shineX = useTransform(sx, (v) => `${50 + v}%`)
  const shineY = useTransform(sy, (v) => `${50 + v}%`)

  function onMove(e: React.MouseEvent<HTMLDivElement>) {
    const el = containerRef.current
    if (!el) return
    const rect = el.getBoundingClientRect()
    const cx = e.clientX - rect.left - rect.width / 2
    const cy = e.clientY - rect.top - rect.height / 2
    mx.set(Math.max(-40, Math.min(40, cx / 4)))
    my.set(Math.max(-40, Math.min(40, cy / 4)))
  }

  function formatCardNumber(value: string) {
    const v = value.replace(/\s+/g, '').replace(/[^0-9]/gi, '')
    const parts = []
    for (let i = 0; i < v.length; i += 4) {
      parts.push(v.substring(i, i + 4))
    }
    return parts.length > 1 ? parts.join(' ') : value
  }

  function handleNumberChange(e: React.ChangeEvent<HTMLInputElement>) {
    const val = e.target.value.replace(/\s+/g, '').replace(/[^0-9]/gi, '')
    if (val.length > 16) return
    setNumber(val)
  }

  function formatExpiry(value: string) {
     const v = value.replace(/\D/g, '');
     if (v.length >= 2) {
        return `${v.slice(0, 2)}/${v.slice(2, 4)}`;
     }
     return v;
  }
  
  function handleExpiryChange(e: React.ChangeEvent<HTMLInputElement>) {
    let val = e.target.value.replace(/\D/g, '');
    if (val.length > 4) return;
    setExpiry(val);
  }

  function pay() {
    if (!number || !name || !expiry || !cvv) {
      setSuccess({ open: true, msg: 'Enter all fields to pay' })
      setTimeout(() => setSuccess({ open: false, msg: '' }), 1800)
      return
    }
    confetti({ particleCount: 120, spread: 80, startVelocity: 45, origin: { y: 0.7 }, colors: ['#ffffff', '#a8a8a8', '#636363'] })
    setSuccess({ open: true, msg: 'Payment Successful' })
    setTimeout(() => setSuccess({ open: false, msg: '' }), 2500)
  }

  return (
    <div className="w-full grid place-items-center py-10">
      <div className="w-full max-w-4xl grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
        <div
          className="relative perspective-[1200px]"
          ref={containerRef}
          onMouseMove={onMove}
          onMouseLeave={() => {
            mx.set(0)
            my.set(0)
          }}
        >
          <motion.div
            animate={{ rotateY: flipped ? 180 : 0 }}
            style={{ rotateX, rotateY }}
            transition={{ type: 'spring', stiffness: 240, damping: 26 }}
            className="relative preserve-3d w-full aspect-[1.586] rounded-2xl shadow-2xl"
          >
            {/* Front */}
            <div className="absolute inset-0 backface-hidden rounded-2xl border border-white/10 bg-[#1a1a1a] overflow-hidden">
               {/* Background Texture */}
               <div className="absolute inset-0 opacity-30" style={{ backgroundImage: 'radial-gradient(circle at 100% 0%, rgba(255,255,255,0.1) 0%, transparent 50%), radial-gradient(circle at 0% 100%, rgba(255,255,255,0.05) 0%, transparent 50%)' }}></div>
               <div className="absolute inset-0 opacity-[0.15]" style={{ filter: 'noise(0.5)' }}></div>

              <div className="relative h-full flex flex-col justify-between p-6 z-10">
                <div className="flex items-start justify-between">
                   <div className="w-12 h-8 rounded bg-gradient-to-br from-[#ffd700] to-[#b8860b] relative overflow-hidden border border-white/20 shadow-sm">
                        <div className="absolute inset-0 opacity-30 bg-[repeating-linear-gradient(45deg,transparent,transparent_2px,black_2px,black_3px)]"></div>
                   </div>
                   <div className="text-white font-bold tracking-wider text-xl italic opacity-90">{type}</div>
                </div>

                <div className="space-y-6">
                    <div className="font-mono text-2xl tracking-[0.15em] text-white shadow-black drop-shadow-md">
                        {number ? formatCardNumber(number) : '•••• •••• •••• ••••'}
                    </div>

                    <div className="flex justify-between items-end text-white/90">
                        <div className="space-y-1">
                            <div className="text-[10px] uppercase tracking-widest opacity-70">Card Holder</div>
                            <div className="font-medium tracking-wide uppercase">{name || 'YOUR NAME'}</div>
                        </div>
                        <div className="space-y-1 text-right">
                            <div className="text-[10px] uppercase tracking-widest opacity-70">Expires</div>
                            <div className="font-mono font-medium">{expiry ? formatExpiry(expiry) : 'MM/YY'}</div>
                        </div>
                    </div>
                </div>
              </div>
              
              {/* Shine Effect */}
              <motion.div
                className="absolute inset-0 pointer-events-none mix-blend-overlay"
                style={{
                  background: 'linear-gradient(105deg, transparent 40%, rgba(255,255,255,0.2) 45%, rgba(255,255,255,0.4) 50%, rgba(255,255,255,0.2) 55%, transparent 60%)',
                  backgroundSize: '200% 100%',
                  backgroundPositionX: shineX,
                  backgroundPositionY: shineY,
                }}
              />
            </div>

            {/* Back */}
            <div className="absolute inset-0 rotate-y-180 backface-hidden rounded-2xl border border-white/10 bg-[#1a1a1a] overflow-hidden">
               <div className="absolute inset-0 opacity-30" style={{ backgroundImage: 'radial-gradient(circle at 0% 0%, rgba(255,255,255,0.1) 0%, transparent 50%)' }}></div>
              <div className="w-full h-12 bg-black/80 mt-6 relative z-10" />
              <div className="px-6 pt-6 relative z-10">
                <div className="flex items-center justify-end">
                  <div className="w-full max-w-[120px]">
                      <div className="text-[10px] uppercase tracking-widest opacity-70 text-right mb-1 mr-1 text-white">CVV</div>
                      <div className="h-10 bg-white text-black font-mono flex items-center justify-end px-3 rounded text-lg tracking-widest shadow-inner">
                        {cvv || '•••'}
                      </div>
                  </div>
                </div>
                <div className="mt-8 text-[10px] text-white/40 text-justify leading-relaxed px-2">
                    This card is property of Velocity UI Bank. If found, please return to the nearest branch or call support. 
                    Authorized signature indicates acceptance of terms and conditions.
                </div>
              </div>
            </div>
          </motion.div>
        </div>

        <form className="space-y-5 bg-background/70 dark:bg-white/5 p-8 rounded-3xl border border-foreground/10 dark:border-white/10 backdrop-blur-sm" onSubmit={(e) => e.preventDefault()}>
          <h3 className="text-xl font-medium text-foreground dark:text-white mb-2">Payment Details</h3>
          
          <div className="space-y-2">
            <label className="text-xs font-medium text-muted-foreground ml-1">Card Number</label>
            <input
              inputMode="numeric"
              value={number ? formatCardNumber(number) : ''}
              onChange={handleNumberChange}
              className="w-full rounded-xl bg-foreground/5 dark:bg-black/20 border border-foreground/10 dark:border-white/10 px-4 py-3 text-sm text-foreground dark:text-white placeholder:text-muted-foreground outline-none focus:border-foreground/20 dark:focus:border-white/30 focus:bg-background/80 dark:focus:bg-black/40 transition-all font-mono"
              placeholder="0000 0000 0000 0000"
            />
          </div>
          
          <div className="space-y-2">
            <label className="text-xs font-medium text-muted-foreground ml-1">Card Holder</label>
            <input
              value={name}
              onChange={(e) => setName(e.target.value.toUpperCase())}
              className="w-full rounded-xl bg-foreground/5 dark:bg-black/20 border border-foreground/10 dark:border-white/10 px-4 py-3 text-sm text-foreground dark:text-white placeholder:text-muted-foreground outline-none focus:border-foreground/20 dark:focus:border-white/30 focus:bg-background/80 dark:focus:bg-black/40 transition-all uppercase"
              placeholder="JOHN DOE"
            />
          </div>
          
          <div className="grid grid-cols-2 gap-4">
            <div className="space-y-2">
              <label className="text-xs font-medium text-muted-foreground ml-1">Expiry Date</label>
              <input
                value={expiry ? formatExpiry(expiry) : ''}
                onChange={handleExpiryChange}
                className="w-full rounded-xl bg-foreground/5 dark:bg-black/20 border border-foreground/10 dark:border-white/10 px-4 py-3 text-sm text-foreground dark:text-white placeholder:text-muted-foreground outline-none focus:border-foreground/20 dark:focus:border-white/30 focus:bg-background/80 dark:focus:bg-black/40 transition-all text-center"
                placeholder="MM/YY"
              />
            </div>
            <div className="space-y-2">
              <label className="text-xs font-medium text-white/60 ml-1">CVV / CVC</label>
              <div className="relative">
                <input
                    value={cvv}
                    onChange={(e) => {
                        const val = e.target.value.replace(/\D/g, '').slice(0, 4);
                        setCvv(val);
                    }}
                    onFocus={() => setFlipped(true)}
                    onBlur={() => setFlipped(false)}
                    className="w-full rounded-xl bg-black/20 border border-white/10 px-4 py-3 text-sm text-white placeholder:text-white/20 outline-none focus:border-white/30 focus:bg-black/40 transition-all text-center"
                    placeholder="123"
                    type="password"
                    maxLength={4}
                />
              </div>
            </div>
          </div>
          
          <div className="pt-2">
             <motion.button
               type="submit"
               whileHover={{ scale: 1.02 }}
               whileTap={{ scale: 0.98 }}
               className="w-full py-3.5 rounded-xl bg-white text-black font-semibold text-sm shadow-lg hover:bg-white/90 transition-colors"
               onClick={pay}
             >
               Pay Now
             </motion.button>
          </div>
        </form>
        
        {/* Success Toast */}
        <AnimatePresence mode="wait">
          {success.open && (
            <motion.div
              key="success"
              initial={{ opacity: 0, y: 10 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: 10 }}
              className="fixed bottom-6 right-6 px-3 py-2 rounded-lg border border-foreground/10 dark:border-white/10 bg-background/80 dark:bg-black/60 text-foreground dark:text-white text-xs shadow-lg"
            >
              {success.msg}
            </motion.div>
          )}
        </AnimatePresence>
       </div>
     </div>
   )
 }
 

Props

Component property reference.

NameTypeDefaultDescription
namestring-Cardholder name.
numberstring-Card number masked.
brandstring-Card brand label (e.g., Visa).
classNamestring-Additional CSS classes.
Context Worth Keeping In Orbit

Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.

I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.

I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.