UIPackage
Menu

Framework

Change language

Boilerplate repo

Payment Card

payment-card ui
Boilerplate repo

Premium metallic credit-card visual with embossed type, holographic foil, and per-digit typing animation. Auto-detects brand (Visa, Mastercard, Amex, Discover), flips in 3D, and supports opt-in mouse-parallax tilt and shimmer. Pure presentational — pass typed values in via props.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/payment-card.json
Named registry: npx shadcn-vue@latest add @uipkge/payment-card Installs to: app/components/ui/payment-card/

Examples

Loading interactive previews…

Props

Name Type / Values Default Required
number string '' optional
name string '' optional
expiry string '' optional
cvc string '' optional
brand
'visa''mastercard''amex''discover''unknown''auto'
'auto' optional
flipped boolean false optional
variant
'default''compact'
'default' optional
tilt boolean false optional
shimmer boolean false optional
flip boolean true optional
size
'sm''md''lg'
'md' optional
class string optional

Used by

Files installed (2)

  • app/components/ui/payment-card/PaymentCard.vue 26.5 kB
    <script setup lang="ts">
    import { computed, onUnmounted, ref, watch } from 'vue'
    import { cn } from '@/lib/utils'
    
    type CardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'unknown'
    
    // Brand wordmark paths (from simpleicons.org, MIT-licensed). Single-path SVGs
    // drawn against a 24x24 viewBox. Fill is set to currentColor so the parent
    // controls color.
    const BRAND_PATHS: Record<Exclude<CardBrand, 'unknown'>, string> = {
      visa: 'M9.112 8.262L5.97 15.758H3.92L2.374 9.775c-.094-.368-.175-.503-.461-.658C1.447 8.864.677 8.627 0 8.479l.046-.217h3.3a.904.904 0 01.894.764l.817 4.338 2.018-5.102zm8.033 5.049c.008-1.979-2.736-2.088-2.717-2.972.006-.269.262-.555.822-.628a3.66 3.66 0 011.913.336l.34-1.59a5.207 5.207 0 00-1.814-.333c-1.917 0-3.266 1.02-3.278 2.479-.012 1.079.963 1.68 1.698 2.04.756.367 1.01.603 1.006.931-.005.504-.602.725-1.16.734-.975.015-1.54-.263-1.992-.473l-.351 1.642c.453.208 1.289.39 2.156.398 2.037 0 3.37-1.006 3.377-2.564m5.061 2.447H24l-1.565-7.496h-1.656a.883.883 0 00-.826.55l-2.909 6.946h2.036l.405-1.12h2.488zm-2.163-2.656l1.02-2.815.588 2.815zm-8.16-4.84l-1.603 7.496H8.34l1.605-7.496z',
      mastercard:
        'M11.343 18.031c.058.049.12.098.181.146-1.177.783-2.59 1.238-4.107 1.238C3.32 19.416 0 16.096 0 12c0-4.095 3.32-7.416 7.416-7.416 1.518 0 2.931.456 4.105 1.238-.06.051-.12.098-.165.15C9.6 7.489 8.595 9.688 8.595 12c0 2.311 1.001 4.51 2.748 6.031zm5.241-13.447c-1.52 0-2.931.456-4.105 1.238.06.051.12.098.165.15C14.4 7.489 15.405 9.688 15.405 12c0 2.31-1.001 4.507-2.748 6.031-.058.049-.12.098-.181.146 1.177.783 2.588 1.238 4.107 1.238C20.68 19.416 24 16.096 24 12c0-4.094-3.32-7.416-7.416-7.416zM12 6.174c-.096.075-.189.15-.28.231C10.156 7.764 9.169 9.765 9.169 12c0 2.236.987 4.236 2.551 5.595.09.08.185.158.28.232.096-.074.189-.152.28-.232 1.563-1.359 2.551-3.359 2.551-5.595 0-2.235-.987-4.236-2.551-5.595-.09-.08-.184-.156-.28-.231z',
      amex: 'M16.015 14.378c0-.32-.135-.496-.344-.622-.21-.12-.464-.135-.81-.135h-1.543v2.82h.675v-1.027h.72c.24 0 .39.024.478.125.12.13.104.38.104.55v.35h.66v-.555c-.002-.25-.017-.376-.108-.516-.06-.08-.18-.18-.33-.234l.02-.008c.18-.072.48-.297.48-.747zm-.87.407l-.028-.002c-.09.053-.195.058-.33.058h-.81v-.63h.824c.12 0 .24 0 .33.05.098.048.156.147.15.255 0 .12-.045.215-.134.27zM20.297 15.837H19v.6h1.304c.676 0 1.05-.278 1.05-.884 0-.28-.066-.448-.187-.582-.153-.133-.392-.193-.73-.207l-.376-.015c-.104 0-.18 0-.255-.03-.09-.03-.15-.105-.15-.21 0-.09.017-.166.09-.21.083-.046.177-.066.272-.06h1.23v-.602h-1.35c-.704 0-.958.437-.958.84 0 .9.776.855 1.407.87.104 0 .18.015.225.06.046.03.082.106.082.18 0 .077-.035.15-.08.18-.06.053-.15.07-.277.07zM0 0v10.096L.81 8.22h1.75l.225.464V8.22h2.043l.45 1.02.437-1.013h6.502c.295 0 .56.057.756.236v-.23h1.787v.23c.307-.17.686-.23 1.12-.23h2.606l.24.466v-.466h1.918l.254.465v-.466h1.858v3.948H20.87l-.36-.6v.585h-2.353l-.256-.63h-.583l-.27.614h-1.213c-.48 0-.84-.104-1.08-.24v.24h-2.89v-.884c0-.12-.03-.12-.105-.135h-.105v1.036H6.067v-.48l-.21.48H4.69l-.202-.48v.465H2.235l-.256-.624H1.4l-.256.624H0V24h23.786v-7.108c-.27.135-.613.18-.973.18H21.09v-.255c-.21.165-.57.255-.914.255H14.71v-.9c0-.12-.018-.12-.12-.12h-.075v1.022h-1.8v-1.066c-.298.136-.643.15-.928.136h-.214v.915h-2.18l-.54-.617-.57.6H4.742v-3.93h3.61l.518.602.554-.6h2.412c.28 0 .74.03.942.225v-.24h2.177c.202 0 .644.045.903.225v-.24h3.265v.24c.163-.164.508-.24.803-.24h1.89v.24c.194-.15.464-.24.84-.24h1.176V0H0zM21.156 14.955c.004.005.006.012.01.016.01.01.024.01.032.02l-.042-.035zM23.828 13.082h.065v.555h-.065zM23.865 15.03v-.005c-.03-.025-.046-.048-.075-.07-.15-.153-.39-.215-.764-.225l-.36-.012c-.12 0-.194-.007-.27-.03-.09-.03-.15-.105-.15-.21 0-.09.03-.16.09-.204.076-.045.15-.05.27-.05h1.223v-.588h-1.283c-.69 0-.96.437-.96.84 0 .9.78.855 1.41.87.104 0 .18.015.224.06.046.03.076.106.076.18 0 .07-.034.138-.09.18-.045.056-.136.07-.27.07h-1.288v.605h1.287c.42 0 .734-.118.9-.36h.03c.09-.134.135-.3.135-.523 0-.24-.045-.39-.135-.526zM18.597 14.208v-.583h-2.235V16.458h2.235v-.585h-1.57v-.57h1.533v-.584h-1.532v-.51M13.51 8.787h.685V11.6h-.684zM13.126 9.543l-.007.006c0-.314-.13-.5-.34-.624-.217-.125-.47-.135-.81-.135H10.43v2.82h.674v-1.034h.72c.24 0 .39.03.487.12.122.136.107.378.107.548v.354h.677v-.553c0-.25-.016-.375-.11-.516-.09-.107-.202-.19-.33-.237.172-.07.472-.3.472-.75zm-.855.396h-.015c-.09.054-.195.056-.33.056H11.1v-.623h.825c.12 0 .24.004.33.05.09.04.15.128.15.25s-.047.22-.134.266zM15.92 9.373h.632v-.6h-.644c-.464 0-.804.105-1.02.33-.286.3-.362.69-.362 1.11 0 .512.123.833.36 1.074.232.238.645.31.97.31h.78l.255-.627h1.39l.262.627h1.36v-2.11l1.272 2.11h.95l.002.002V8.786h-.684v1.963l-1.18-1.96h-1.02V11.4L18.11 8.744h-1.004l-.943 2.22h-.3c-.177 0-.362-.03-.468-.134-.125-.15-.186-.36-.186-.662 0-.285.08-.51.194-.63.133-.135.272-.165.516-.165zm1.668-.108l.464 1.118v.002h-.93l.466-1.12zM2.38 10.97l.254.628H4V9.393l.972 2.205h.584l.973-2.202.015 2.202h.69v-2.81H6.118l-.807 1.904-.876-1.905H3.343v2.663L2.205 8.787h-.997L.01 11.597h.72l.26-.626h1.39zm-.688-1.705l.46 1.118-.003.002h-.915l.457-1.12zM11.856 13.62H9.714l-.85.923-.825-.922H5.346v2.82H8l.855-.932.824.93h1.302v-.94h.838c.6 0 1.17-.164 1.17-.945l-.006-.003c0-.78-.598-.93-1.128-.93zM7.67 15.853l-.014-.002H6.02v-.557h1.47v-.574H6.02v-.51H7.7l.733.82-.764.824zm2.642.33l-1.03-1.147 1.03-1.108v2.253zm1.553-1.258h-.885v-.717h.885c.24 0 .42.098.42.344 0 .243-.15.372-.42.372zM9.967 9.373v-.586H7.73V11.6h2.237v-.58H8.4v-.564h1.527V9.88H8.4v-.507',
      discover:
        'M14.58 12a2.023 2.023 0 1 1-2.025-2.023h.002c1.118 0 2.023.906 2.023 2.023zm-5.2-2.001c-1.124 0-2.025.884-2.025 1.99 0 1.118.878 1.984 2.007 1.984.319 0 .593-.063.93-.221v-.873c-.296.297-.559.416-.895.416-.747 0-1.277-.542-1.277-1.312 0-.73.547-1.306 1.243-1.306.354 0 .622.126.93.428v-.873a1.898 1.898 0 0 0-.913-.233zm-3.352 1.545c-.445-.165-.576-.273-.576-.479 0-.239.233-.422.553-.422.222 0 .405.091.598.308l.388-.508a1.665 1.665 0 0 0-1.117-.422c-.673 0-1.186.467-1.186 1.089 0 .524.239.792.936 1.043.291.103.438.171.513.217a.456.456 0 0 1 .222.394c0 .308-.245.536-.576.536-.354 0-.639-.177-.809-.507l-.479.461c.342.502.752.724 1.317.724.771 0 1.311-.513 1.311-1.249-.002-.603-.252-.876-1.095-1.185zM24 10.3a.29.29 0 0 1-.288.291.29.29 0 0 1-.291-.291v-.003A.29.29 0 1 1 24 10.3zm-.059.001a.235.235 0 0 0-.231-.239.234.234 0 0 0-.232.239c0 .132.104.239.232.239a.235.235 0 0 0 .231-.239zM3.472 13.887h.742v-3.803h-.742v3.803zm12.702-1.248l-1.014-2.554h-.81l1.614 3.9h.399l1.643-3.9h-.804l-1.028 2.554zm2.166 1.248h2.104v-.644h-1.362v-1.027h1.312v-.644h-1.312v-.844h1.362v-.644H18.34v3.803zm5.409-3.557l.11.138h-.097l-.094-.13v.13h-.08v-.334h.107c.081 0 .126.036.126.103.001.046-.025.08-.072.093zm-.006-.092c0-.029-.021-.043-.06-.043h-.014v.087h.014c.039 0 .06-.014.06-.044zm-1.228 2.047l1.197 1.602H22.8l-1.027-1.528h-.097v1.528h-.741v-3.803h1.1c.855 0 1.346.411 1.346 1.123 0 .583-.308.965-.866 1.078zm.103-1.038c0-.37-.251-.563-.713-.563h-.228v1.152h.217c.473-.001.724-.207.724-.589zm-19.487.742a1.91 1.91 0 0 1-.69 1.46c-.365.303-.781.439-1.357.439H.001v-3.803H1.09c1.202 0 2.041.781 2.041 1.904zm-.764-.006c0-.364-.154-.718-.411-.947-.245-.222-.536-.308-1.015-.308H.742v2.515h.199c.479 0 .782-.092 1.015-.302.256-.228.411-.593.411-.958z',
    }
    
    // Tight viewBox per brand so the visible glyph fills its slot.
    const BRAND_VIEWBOX: Record<Exclude<CardBrand, 'unknown'>, string> = {
      visa: '0 7 24 10',
      mastercard: '0 4 24 16',
      amex: '0 0 24 24',
      discover: '0 8 24 8',
    }
    
    // Premium metallic faces per brand — deep color, white embossed type, gold chip.
    const BRAND_THEME: Record<
      CardBrand,
      { gradient: string; logo: string; accent: string; sheen: string; glow: string }
    > = {
      visa: {
        gradient: 'bg-[linear-gradient(145deg,#0b1f4d_0%,#1a4fb8_42%,#0c2d7a_72%,#071536_100%)]',
        logo: 'text-white',
        accent: 'from-sky-300/40 via-indigo-400/20 to-transparent',
        sheen: 'from-white/25 via-sky-200/10 to-transparent',
        glow: 'shadow-[0_18px_40px_-14px_rgba(30,64,175,0.65),0_8px_16px_-10px_rgba(0,0,0,0.45),inset_0_1px_0_0_rgba(255,255,255,0.28)]',
      },
      mastercard: {
        gradient: 'bg-[linear-gradient(145deg,#3b0a12_0%,#b91c1c_38%,#c2410c_68%,#450a0a_100%)]',
        logo: 'text-white',
        accent: 'from-orange-300/35 via-red-400/20 to-transparent',
        sheen: 'from-white/22 via-orange-200/10 to-transparent',
        glow: 'shadow-[0_18px_40px_-14px_rgba(185,28,28,0.55),0_8px_16px_-10px_rgba(0,0,0,0.45),inset_0_1px_0_0_rgba(255,255,255,0.25)]',
      },
      amex: {
        gradient: 'bg-[linear-gradient(145deg,#042f2e_0%,#0f766e_40%,#115e59_70%,#022c22_100%)]',
        logo: 'text-white',
        accent: 'from-teal-200/40 via-emerald-300/15 to-transparent',
        sheen: 'from-white/22 via-teal-100/10 to-transparent',
        glow: 'shadow-[0_18px_40px_-14px_rgba(13,148,136,0.55),0_8px_16px_-10px_rgba(0,0,0,0.45),inset_0_1px_0_0_rgba(255,255,255,0.25)]',
      },
      discover: {
        gradient: 'bg-[linear-gradient(145deg,#431407_0%,#c2410c_40%,#b45309_70%,#292524_100%)]',
        logo: 'text-white',
        accent: 'from-amber-200/40 via-orange-300/15 to-transparent',
        sheen: 'from-white/22 via-amber-100/10 to-transparent',
        glow: 'shadow-[0_18px_40px_-14px_rgba(194,65,12,0.55),0_8px_16px_-10px_rgba(0,0,0,0.45),inset_0_1px_0_0_rgba(255,255,255,0.25)]',
      },
      unknown: {
        gradient: 'bg-[linear-gradient(145deg,#0f172a_0%,#334155_45%,#1e293b_75%,#020617_100%)]',
        logo: 'text-white/90',
        accent: 'from-slate-200/30 via-slate-400/10 to-transparent',
        sheen: 'from-white/18 via-slate-200/8 to-transparent',
        glow: 'shadow-[0_18px_40px_-14px_rgba(15,23,42,0.7),0_8px_16px_-10px_rgba(0,0,0,0.5),inset_0_1px_0_0_rgba(255,255,255,0.2)]',
      },
    }
    
    const props = withDefaults(
      defineProps<{
        number?: string
        name?: string
        expiry?: string
        cvc?: string
        brand?: 'visa' | 'mastercard' | 'amex' | 'discover' | 'unknown' | 'auto'
        flipped?: boolean
        variant?: 'default' | 'compact'
        tilt?: boolean
        shimmer?: boolean
        flip?: boolean
        size?: 'sm' | 'md' | 'lg'
        class?: string
      }>(),
      {
        number: '',
        name: '',
        expiry: '',
        cvc: '',
        brand: 'auto',
        flipped: false,
        variant: 'default',
        tilt: false,
        shimmer: false,
        flip: true,
        size: 'md',
      },
    )
    
    function detectBrand(raw: string): CardBrand {
      const n = raw.replace(/\D/g, '')
      if (!n) return 'unknown'
      if (/^4/.test(n)) return 'visa'
      if (/^(5[1-5]|2[2-7])/.test(n)) return 'mastercard'
      if (/^3[47]/.test(n)) return 'amex'
      if (/^(6011|65|64[4-9])/.test(n)) return 'discover'
      return 'unknown'
    }
    
    const detectedBrand = computed<CardBrand>(() =>
      props.brand === 'auto' ? detectBrand(props.number) : (props.brand as CardBrand),
    )
    
    function maskedNumber(raw: string, brand: CardBrand): string {
      const digits = raw.replace(/\D/g, '').slice(0, brand === 'amex' ? 15 : 16)
      const groups = brand === 'amex' ? [4, 6, 5] : [4, 4, 4, 4]
      const total = groups.reduce((a, b) => a + b, 0)
      const padded = (digits + ''.repeat(total)).slice(0, total)
      let i = 0
      return groups
        .map((g) => {
          const slice = padded.slice(i, i + g)
          i += g
          return slice
        })
        .join(' ')
    }
    
    const displayNumber = computed(() => maskedNumber(props.number, detectedBrand.value))
    const displayName = computed(() => {
      const fallback = props.variant === 'compact' ? '' : 'CARDHOLDER NAME'
      return (props.name || fallback).toUpperCase()
    })
    const displayExpiry = computed(() => props.expiry || 'MM/YY')
    const displayCvc = computed(() => {
      const want = detectedBrand.value === 'amex' ? 4 : 3
      const v = (props.cvc || '').replace(/\D/g, '').slice(0, want)
      return v.padEnd(want, '')
    })
    
    const theme = computed(() => BRAND_THEME[detectedBrand.value])
    
    // Per-digit pop: bump a generation key when a digit changes so the span remounts
    // and the CSS animation always restarts (class-toggle alone is flaky under rapid input).
    const displayChars = computed(() => [...displayNumber.value])
    const charGen = ref<number[]>([])
    const previousDisplay = ref(displayNumber.value)
    const isFirstDisplay = ref(true)
    
    watch(
      displayNumber,
      (next) => {
        const nextChars = [...next]
        if (isFirstDisplay.value) {
          isFirstDisplay.value = false
          previousDisplay.value = next
          charGen.value = nextChars.map(() => 0)
          return
        }
        const prev = previousDisplay.value
        const gens = nextChars.map((ch, i) => {
          const prevGen = charGen.value[i] ?? 0
          // Animate real digits that just appeared or changed (not spaces / mask bullets).
          if (ch !== ' ' && ch !== '' && prev[i] !== ch) return prevGen + 1
          return prevGen
        })
        previousDisplay.value = next
        charGen.value = gens
      },
      { immediate: true },
    )
    
    function charKey(i: number, ch: string) {
      return `${i}-${ch}-${charGen.value[i] ?? 0}`
    }
    
    function shouldPop(i: number, ch: string) {
      return ch !== ' ' && ch !== '' && (charGen.value[i] ?? 0) > 0
    }
    
    const sizeClass = computed(() => {
      if (props.variant === 'compact') return 'w-[120px]'
      return { sm: 'w-[280px]', md: 'w-[340px]', lg: 'w-[400px]' }[props.size]
    })
    
    const fontClass = computed(() => {
      if (props.variant === 'compact') return 'text-[7px]'
      return { sm: 'text-xs', md: 'text-sm', lg: 'text-base' }[props.size]
    })
    
    const numberFontClass = computed(() => {
      if (props.variant === 'compact') return 'text-[7px] tracking-tight'
      return { sm: 'text-base tracking-wider', md: 'text-lg tracking-wider', lg: 'text-xl tracking-widest' }[props.size]
    })
    
    const brandHeightClass = computed(() => {
      if (props.variant === 'compact') return 'h-2.5'
      return { sm: 'h-4', md: 'h-5', lg: 'h-6' }[props.size]
    })
    
    // Tilt — mouse-parallax
    const cardRef = ref<HTMLDivElement | null>(null)
    const tiltX = ref(0)
    const tiltY = ref(0)
    let rafId: number | null = null
    
    function onMove(e: MouseEvent) {
      if (!props.tilt || !cardRef.value) return
      if (rafId) cancelAnimationFrame(rafId)
      rafId = requestAnimationFrame(() => {
        if (!cardRef.value) return
        const rect = cardRef.value.getBoundingClientRect()
        const dx = (e.clientX - rect.left) / rect.width - 0.5
        const dy = (e.clientY - rect.top) / rect.height - 0.5
        tiltY.value = dx * 16
        tiltX.value = -dy * 16
      })
    }
    function onLeave() {
      tiltX.value = 0
      tiltY.value = 0
    }
    
    const innerTransform = computed(() => {
      const flipDeg = props.flipped ? 180 : 0
      return `rotateX(${tiltX.value}deg) rotateY(${tiltY.value + flipDeg}deg)`
    })
    
    onUnmounted(() => {
      if (rafId) cancelAnimationFrame(rafId)
    })
    </script>
    
    <template>
      <div
        ref="cardRef"
        data-uipkge
        data-slot="payment-card"
        role="img"
        :aria-label="`${detectedBrand === 'unknown' ? 'Card' : detectedBrand} ending ${displayNumber.replace(/[•\s]/g, '').slice(-4) || '••••'}, ${displayName}, expires ${displayExpiry}`"
        :class="cn('group relative inline-block select-none [perspective:1200px]', sizeClass, props.class)"
        @mousemove="onMove"
        @mouseleave="onLeave"
      >
        <div
          class="relative aspect-[1.586/1] w-full transition-transform [transform-style:preserve-3d]"
          :class="flip ? 'duration-[700ms] ease-[cubic-bezier(0.22,1,0.36,1)]' : 'duration-300'"
          :style="{ transform: innerTransform }"
        >
          <!-- FRONT -->
          <div
            :class="
              cn(
                'pay-card-face absolute inset-0 overflow-hidden rounded-[18px] [backface-visibility:hidden]',
                theme.gradient,
                theme.glow,
              )
            "
          >
            <!-- Fine grain noise -->
            <div class="pay-card-noise pointer-events-none absolute inset-0 opacity-[0.18] mix-blend-overlay" />
            <!-- Corner accent bloom -->
            <div
              :class="cn('pointer-events-none absolute -top-10 -right-8 h-40 w-40 rounded-full bg-gradient-to-br blur-2xl', theme.accent)"
            />
            <div
              class="pointer-events-none absolute -bottom-12 -left-10 h-36 w-36 rounded-full bg-black/30 blur-2xl"
            />
            <!-- Specular top edge -->
            <div
              class="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/50 to-transparent"
            />
            <!-- Diagonal foil sheen -->
            <div
              :class="
                cn(
                  'pointer-events-none absolute -inset-y-8 -left-1/3 w-1/2 rotate-12 bg-gradient-to-r opacity-70',
                  theme.sheen,
                )
              "
            />
            <!-- Soft lighting -->
            <div
              class="pointer-events-none absolute inset-0"
              style="background: radial-gradient(120% 80% at 0% 0%, rgba(255, 255, 255, 0.22) 0%, transparent 55%)"
            />
            <div
              class="pointer-events-none absolute inset-0"
              style="background: radial-gradient(80% 60% at 100% 100%, rgba(0, 0, 0, 0.35) 0%, transparent 60%)"
            />
            <!-- Holographic strip -->
            <div
              v-if="variant !== 'compact'"
              class="pay-card-holo pointer-events-none absolute top-[42%] right-3 h-8 w-11 rounded-sm opacity-80"
            />
            <!-- Shimmer sweep (opt-in) -->
            <div
              v-if="shimmer"
              class="pointer-events-none absolute -inset-x-full inset-y-0 bg-gradient-to-r from-transparent via-white/30 to-transparent"
              style="animation: payment-card-shimmer 2.5s linear infinite"
            />
    
            <div :class="cn('absolute inset-0 flex flex-col text-white', variant === 'compact' ? 'p-2' : 'p-4')">
              <!-- Brand top-right -->
              <div class="flex h-auto items-center justify-end drop-shadow-[0_1px_2px_rgba(0,0,0,0.35)]" style="perspective: 600px">
                <Transition name="brand-pop" mode="out-in">
                  <svg
                    v-if="detectedBrand !== 'unknown'"
                    :key="detectedBrand"
                    :viewBox="BRAND_VIEWBOX[detectedBrand]"
                    :class="[brandHeightClass, 'pay-brand-mark w-auto', theme.logo]"
                    fill="currentColor"
                    preserveAspectRatio="xMidYMid meet"
                    aria-hidden="true"
                  >
                    <path :d="BRAND_PATHS[detectedBrand]" />
                  </svg>
                  <span
                    v-else
                    key="unknown"
                    class="pay-brand-mark font-semibold tracking-[0.2em] opacity-80"
                    :class="[variant === 'compact' ? 'text-[8px]' : 'text-xs', theme.logo]"
                    >CARD</span
                  >
                </Transition>
              </div>
    
              <!-- Chip + contactless NFC -->
              <div :class="cn('flex items-center gap-2.5', variant === 'compact' ? 'mt-1' : 'mt-3')">
                <!-- EMV chip — metallic gold -->
                <div
                  :class="
                    cn(
                      'pay-card-chip relative rounded-[5px]',
                      variant === 'compact' ? 'h-3 w-4' : 'h-8 w-11',
                    )
                  "
                >
                  <div class="absolute inset-[2px] rounded-[3px] border border-amber-950/25" />
                  <div class="absolute inset-x-[3px] top-1/2 h-px -translate-y-px bg-amber-950/30" />
                  <div class="absolute inset-y-[3px] left-1/3 w-px bg-amber-950/30" />
                  <div class="absolute inset-y-[3px] left-2/3 w-px bg-amber-950/30" />
                </div>
    
                <!-- Contactless NFC waves -->
                <svg
                  v-if="variant !== 'compact'"
                  viewBox="0 0 24 24"
                  class="h-6 w-auto text-white/75 drop-shadow-sm"
                  fill="none"
                  stroke="currentColor"
                  stroke-width="1.6"
                  stroke-linecap="round"
                  aria-hidden="true"
                >
                  <path d="M7 8.5a6 6 0 0 1 0 7" />
                  <path d="M10 6a9.5 9.5 0 0 1 0 12" />
                  <path d="M13 4a13 13 0 0 1 0 16" />
                </svg>
              </div>
    
              <!-- spacer -->
              <div class="flex-1" />
    
              <!-- Number — embossed mono -->
              <div
                :class="
                  cn(
                    'pay-card-emboss flex flex-nowrap overflow-visible font-mono font-semibold leading-none tracking-wider',
                    variant === 'compact' ? 'mb-1' : 'mb-3',
                    numberFontClass,
                  )
                "
              >
                <span class="sr-only">{{ displayNumber }}</span>
                <span
                  v-for="(ch, i) in displayChars"
                  :key="charKey(i, ch)"
                  :class="
                    cn(
                      'inline-block origin-bottom tabular-nums will-change-transform',
                      ch === '•' && 'opacity-45',
                      shouldPop(i, ch) && 'pay-char-pop',
                    )
                  "
                  aria-hidden="true"
                >
                  {{ ch === ' ' ? '\u00A0' : ch }}
                </span>
              </div>
    
              <!-- Bottom row: name + expiry -->
              <div class="flex items-end justify-between gap-2">
                <div class="min-w-0 flex-1">
                  <div
                    v-if="variant !== 'compact'"
                    class="text-[9px] font-medium tracking-[0.2em] text-white/55 uppercase"
                  >
                    Cardholder
                  </div>
                  <div
                    :class="
                      cn(
                        'pay-card-emboss truncate font-semibold tracking-wide whitespace-nowrap uppercase',
                        fontClass,
                      )
                    "
                  >
                    {{ displayName }}
                  </div>
                </div>
                <div class="shrink-0 text-right">
                  <div
                    v-if="variant !== 'compact'"
                    class="text-[9px] font-medium tracking-[0.2em] text-white/55 uppercase"
                  >
                    Expires
                  </div>
                  <div :class="cn('pay-card-emboss font-mono font-semibold tracking-wide whitespace-nowrap', fontClass)">
                    {{ displayExpiry }}
                  </div>
                </div>
              </div>
            </div>
          </div>
    
          <!-- BACK -->
          <div
            :class="
              cn(
                'pay-card-face absolute inset-0 [transform:rotateY(180deg)] overflow-hidden rounded-[18px] [backface-visibility:hidden]',
                theme.gradient,
                theme.glow,
              )
            "
          >
            <div class="pay-card-noise pointer-events-none absolute inset-0 opacity-[0.16] mix-blend-overlay" />
            <div
              class="pointer-events-none absolute inset-0"
              style="background: radial-gradient(120% 80% at 0% 0%, rgba(255, 255, 255, 0.16) 0%, transparent 55%)"
            />
            <!-- Magstripe -->
            <div
              :class="
                cn(
                  'w-full bg-gradient-to-b from-neutral-950 via-neutral-900 to-black shadow-[inset_0_1px_0_rgba(255,255,255,0.08),inset_0_-1px_0_rgba(0,0,0,0.55)]',
                  variant === 'compact' ? 'mt-3 h-5' : 'mt-6 h-10',
                )
              "
            />
    
            <div :class="cn('relative text-white', variant === 'compact' ? 'p-2' : 'p-4')">
              <!-- Signature strip + CVC -->
              <div :class="cn('flex items-stretch gap-2', variant === 'compact' ? 'mt-1' : 'mt-2')">
                <div
                  :class="cn('relative flex-1 overflow-hidden rounded-sm px-2 py-1.5', variant === 'compact' ? 'h-5' : 'h-9')"
                  style="
                    background-image: repeating-linear-gradient(135deg, rgba(0, 0, 0, 0.06) 0 2px, transparent 2px 6px);
                    background-color: rgba(248, 250, 252, 0.96);
                  "
                >
                  <div
                    v-if="variant !== 'compact'"
                    class="absolute top-1 right-1.5 text-[7px] tracking-wider text-slate-400 uppercase"
                  >
                    Signature
                  </div>
                </div>
                <div
                  :class="
                    cn(
                      'flex flex-col items-center justify-center rounded-sm bg-white font-mono text-slate-900 shadow-[0_2px_8px_rgba(0,0,0,0.25)]',
                      variant === 'compact' ? 'px-1 text-[9px]' : 'px-2.5 text-sm',
                    )
                  "
                >
                  <span v-if="variant !== 'compact'" class="text-[7px] font-medium tracking-wider text-slate-400 uppercase"
                    >CVC</span
                  >
                  <span class="leading-none font-semibold">{{ displayCvc }}</span>
                </div>
              </div>
    
              <div v-if="variant !== 'compact'" class="mt-3 text-[9px] tracking-wide text-white/45">
                Authorized signature. Not valid unless signed. For customer service, see your card issuer.
              </div>
            </div>
          </div>
        </div>
      </div>
    </template>
    
    <style>
    @keyframes payment-card-shimmer {
      0% {
        transform: translateX(-50%);
      }
      100% {
        transform: translateX(150%);
      }
    }
    
    @keyframes pay-char-pop {
      0% {
        opacity: 0;
        transform: translateY(10px) scale(0.55);
      }
      55% {
        opacity: 1;
        transform: translateY(-3px) scale(1.18);
      }
      100% {
        opacity: 1;
        transform: translateY(0) scale(1);
      }
    }
    
    .pay-char-pop {
      animation: pay-char-pop 380ms cubic-bezier(0.22, 1.4, 0.36, 1) both;
    }
    
    .pay-card-face {
      border: 1px solid rgba(255, 255, 255, 0.12);
    }
    
    .pay-card-noise {
      background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
      background-size: 140px 140px;
    }
    
    .pay-card-chip {
      background: linear-gradient(145deg, #fde68a 0%, #f59e0b 35%, #b45309 70%, #fbbf24 100%);
      box-shadow:
        inset 0 1px 0 rgba(255, 255, 255, 0.55),
        inset 0 -1px 0 rgba(0, 0, 0, 0.25),
        0 2px 4px rgba(0, 0, 0, 0.35);
    }
    
    .pay-card-holo {
      background: linear-gradient(
        125deg,
        rgba(255, 255, 255, 0.15) 0%,
        rgba(125, 211, 252, 0.55) 20%,
        rgba(196, 181, 253, 0.55) 40%,
        rgba(253, 186, 116, 0.5) 60%,
        rgba(134, 239, 172, 0.45) 80%,
        rgba(255, 255, 255, 0.2) 100%
      );
      box-shadow:
        inset 0 0 0 1px rgba(255, 255, 255, 0.25),
        0 2px 6px rgba(0, 0, 0, 0.25);
    }
    
    .pay-card-emboss {
      text-shadow:
        0 1px 0 rgba(255, 255, 255, 0.22),
        0 -1px 0 rgba(0, 0, 0, 0.35),
        0 2px 6px rgba(0, 0, 0, 0.25);
    }
    
    /* Brand mark needs a block box so rotateY actually reads. */
    .pay-brand-mark {
      display: block;
      transform-origin: center center;
      transform-style: preserve-3d;
      backface-visibility: hidden;
      filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.35));
    }
    
    .brand-pop-enter-active,
    .brand-pop-leave-active {
      transition:
        opacity 320ms cubic-bezier(0.22, 1, 0.36, 1),
        transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
      transform-style: preserve-3d;
      backface-visibility: hidden;
    }
    
    .brand-pop-enter-from {
      opacity: 0;
      transform: scale(0.65) rotateY(-95deg);
    }
    
    .brand-pop-leave-to {
      opacity: 0;
      transform: scale(0.65) rotateY(95deg);
    }
    
    @media (prefers-reduced-motion: reduce) {
      [data-slot='payment-card'] [class*='transition-'] {
        transition-duration: 0ms !important;
      }
      [data-slot='payment-card'] [style*='payment-card-shimmer'] {
        animation: none !important;
      }
      .pay-char-pop {
        animation: none !important;
      }
      .brand-pop-enter-active,
      .brand-pop-leave-active {
        transition-duration: 0ms !important;
      }
    }
    </style>
  • app/components/ui/payment-card/index.ts 0.1 kB
    export { default as PaymentCard } from './PaymentCard.vue'

Raw manifest: https://uipkge.dev/r/vue/payment-card.json