Skip to content

Wallet Card ​

A stack of payment cards that springs a card to the front, flips it for details and rolls in its balance.

Everyday balance

$4,821.36

Tap card for details

  • Blue Bottle CoffeeToday, 08:12−$6.50
  • Salary — NorthwindYesterday+$3,200.00
  • Rail PassOct 29−$42.00

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/wallet-card.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then copy these files into components/ui/wallet-card/ (plus lib/utils.ts if you don't have it):

vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { AnimatePresence, motion, useReducedMotion } from 'motion-v'
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
import { cn } from '@/lib/utils'
import RollingNumber from './RollingNumber.vue'

export interface WalletTransaction {
  id: string
  merchant: string
  /** Negative for spending, positive for income. */
  amount: number
  /** Display date, e.g. "Today, 09:41". */
  date: string
}

export interface WalletCardItem {
  id: string
  /** Network or issuer shown on the card, e.g. "VISA". */
  brand: string
  /** Card nickname, e.g. "Everyday". */
  label: string
  /** Full card number; only the last four digits show on the front. */
  number: string
  holder: string
  /** "MM/YY" */
  expiry: string
  cvv: string
  balance: number
  /** Any CSS background (color or gradient). Defaults to the primary token. */
  background?: string
  /** Text color on the card when `background` is set. */
  foreground?: string
  transactions?: WalletTransaction[]
}

const props = withDefaults(defineProps<{
  cards: WalletCardItem[]
  currency?: string
  locale?: string
  /** Pixels each card behind the front one peeks out. */
  peek?: number
  class?: HTMLAttributes['class']
}>(), {
  currency: 'USD',
  peek: 16,
})

const active = defineModel<string>('active')
if (active.value === undefined && props.cards.length)
  active.value = props.cards[0]!.id

const uid = useId()
const reduced = useReducedMotion()
const stackSpring = { type: 'spring', stiffness: 300, damping: 30, mass: 0.9 } as const
const flipSpring = { type: 'spring', stiffness: 170, damping: 20 } as const
const listSpring = { type: 'spring', stiffness: 380, damping: 32 } as const

const flipped = ref(false)
const lifting = ref<string | null>(null)
let timer: ReturnType<typeof setTimeout> | undefined
onBeforeUnmount(() => clearTimeout(timer))
watch(active, () => (flipped.value = false))

// Active card first, the rest keep their relative order behind it.
const order = computed(() => {
  const front = props.cards.find(c => c.id === active.value) ?? props.cards[0]
  return front ? [front, ...props.cards.filter(c => c !== front)] : []
})
const depthOf = (id: string) => order.value.findIndex(c => c.id === id)
const current = computed(() => order.value[0])
const visibleDepth = computed(() => Math.min(order.value.length, 3) - 1)

const last4 = (n: string) => n.replace(/\D/g, '').slice(-4)
const groups = (n: string) => n.replace(/\D/g, '').replace(/(.{4})(?=.)/g, '$1 ')
const money = computed(() => new Intl.NumberFormat(props.locale, { style: 'currency', currency: props.currency }))
const signed = (v: number) => `${v > 0 ? '+' : v < 0 ? '−' : ''}${money.value.format(Math.abs(v))}`

function target(id: string) {
  const d = depthOf(id)
  const shown = Math.min(d, visibleDepth.value)
  const lift = lifting.value === id
  return {
    // A card being pulled forward first rises out of the stack, then drops in front.
    y: lift ? -shown * props.peek - 110 : -shown * props.peek,
    scale: lift ? 1 - shown * 0.04 : 1 - shown * 0.06,
    opacity: d > visibleDepth.value ? 0 : 1,
  }
}

function bringToFront(id: string) {
  if (id === active.value || lifting.value)
    return
  if (reduced.value) {
    active.value = id
    return
  }
  lifting.value = id
  clearTimeout(timer)
  timer = setTimeout(() => {
    active.value = id
    lifting.value = null
  }, 240)
}

const stack = ref<HTMLElement>()
function onCardClick(id: string) {
  if (id === active.value)
    flipped.value = !flipped.value
  else
    bringToFront(id)
}
async function onKeydown(e: KeyboardEvent) {
  const n = props.cards.length
  if (e.key === 'Escape' && flipped.value) {
    flipped.value = false
    return
  }
  const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[e.key]
  if (!step || n < 2)
    return
  e.preventDefault()
  const i = props.cards.findIndex(c => c.id === active.value)
  const next = props.cards[(i + step + n) % n]!
  active.value = next.id
  await nextTick()
  stack.value?.querySelector<HTMLElement>(`[data-card="${next.id}"]`)?.focus()
}
</script>

<template>
  <div :class="cn('flex w-full max-w-sm flex-col', props.class)">
    <!-- Card stack -->
    <div
      ref="stack"
      role="group"
      aria-roledescription="card stack"
      aria-label="Payment cards"
      class="relative aspect-[1.586] w-full"
      :style="{ marginTop: `${visibleDepth * props.peek}px` }"
      @keydown="onKeydown"
    >
      <motion.button
        v-for="card in props.cards"
        :key="card.id"
        type="button"
        :data-card="card.id"
        :aria-label="depthOf(card.id) === 0
          ? `${card.label} card ending ${last4(card.number)}. ${flipped ? 'Hide' : 'Show'} card details`
          : `Switch to ${card.label} card ending ${last4(card.number)}`"
        :aria-pressed="depthOf(card.id) === 0 ? flipped : undefined"
        :aria-describedby="depthOf(card.id) === 0 && flipped ? `${uid}-details` : undefined"
        :tabindex="depthOf(card.id) > visibleDepth ? -1 : 0"
        class="absolute inset-0 origin-top rounded-2xl text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
        :style="{ zIndex: props.cards.length - depthOf(card.id) }"
        :initial="false"
        :animate="target(card.id)"
        :transition="reduced ? { duration: 0 } : stackSpring"
        :while-hover="depthOf(card.id) > 0 && !reduced ? { y: target(card.id).y - 6 } : undefined"
        @click="onCardClick(card.id)"
      >
        <motion.div
          class="relative size-full [transform-style:preserve-3d]"
          :style="{ transformPerspective: 1200 }"
          :initial="false"
          :animate="{ rotateY: depthOf(card.id) === 0 && flipped ? 180 : 0 }"
          :transition="reduced ? { duration: 0 } : flipSpring"
        >
          <!-- Front face -->
          <div
            :class="cn(
              'absolute inset-0 flex flex-col justify-between overflow-hidden rounded-2xl p-5 shadow-lg ring-1 ring-black/5 [backface-visibility:hidden]',
              !card.background && 'bg-primary text-primary-foreground',
            )"
            :style="card.background ? { background: card.background, color: card.foreground } : undefined"
          >
            <div
              aria-hidden="true"
              class="pointer-events-none absolute inset-0"
              :style="{ background: 'radial-gradient(120% 80% at 0% 0%, color-mix(in oklab, currentColor 18%, transparent), transparent 60%)' }"
            />
            <div class="relative flex items-start justify-between">
              <span class="text-xs font-medium uppercase tracking-[0.18em] opacity-80">{{ card.label }}</span>
              <span class="text-lg font-bold italic tracking-tight">{{ card.brand }}</span>
            </div>
            <div class="relative flex items-center gap-3">
              <svg class="h-7 w-9 opacity-80" viewBox="0 0 36 28" fill="none" aria-hidden="true">
                <rect x="0.75" y="0.75" width="34.5" height="26.5" rx="5" stroke="currentColor" stroke-width="1.5" />
                <path d="M12 1v26M24 1v26M1 10h11M24 10h11M1 18h11M24 18h11" stroke="currentColor" stroke-width="1.2" />
              </svg>
              <svg class="size-5 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
                <path d="M8.5 8.5a5 5 0 0 1 0 7M12 6a8.5 8.5 0 0 1 0 12M15.5 3.5a12 12 0 0 1 0 17" />
              </svg>
            </div>
            <div class="relative">
              <p class="m-0 font-mono text-base tracking-[0.2em]">
                •••• {{ last4(card.number) }}
              </p>
              <div class="mt-1.5 flex items-end justify-between text-xs opacity-80">
                <span class="truncate uppercase tracking-wider">{{ card.holder }}</span>
                <span class="tabular-nums">{{ card.expiry }}</span>
              </div>
            </div>
          </div>

          <!-- Back face -->
          <div
            :id="depthOf(card.id) === 0 ? `${uid}-details` : undefined"
            :class="cn(
              'absolute inset-0 flex flex-col overflow-hidden rounded-2xl shadow-lg ring-1 ring-black/5 [backface-visibility:hidden] [transform:rotateY(180deg)]',
              !card.background && 'bg-primary text-primary-foreground',
            )"
            :style="card.background ? { background: card.background, color: card.foreground } : undefined"
          >
            <div aria-hidden="true" class="mt-5 h-9 w-full bg-black/50" />
            <div class="flex flex-1 flex-col justify-between p-5 pt-4">
              <div class="flex items-center gap-3">
                <div
                  class="h-8 flex-1 rounded-md"
                  :style="{ background: 'repeating-linear-gradient(-45deg, color-mix(in oklab, currentColor 22%, transparent) 0 6px, color-mix(in oklab, currentColor 10%, transparent) 6px 12px)' }"
                  aria-hidden="true"
                />
                <div class="flex flex-col items-end">
                  <span class="text-[10px] uppercase tracking-wider opacity-70">CVV</span>
                  <span class="font-mono text-sm font-semibold">{{ depthOf(card.id) === 0 && flipped ? card.cvv : '•••' }}</span>
                </div>
              </div>
              <div>
                <p class="m-0 font-mono text-[15px] tracking-[0.12em]">
                  {{ depthOf(card.id) === 0 && flipped ? groups(card.number) : '•••• •••• •••• ••••' }}
                </p>
                <div class="mt-1.5 flex justify-between text-xs opacity-80">
                  <span class="truncate uppercase tracking-wider">{{ card.holder }}</span>
                  <span class="tabular-nums">Exp {{ card.expiry }}</span>
                </div>
              </div>
            </div>
          </div>
        </motion.div>
      </motion.button>
    </div>

    <!-- Balance -->
    <div v-if="current" class="mt-5 flex items-end justify-between gap-4 px-1">
      <div class="min-w-0">
        <p class="m-0 text-xs text-muted-foreground">
          {{ current.label }} balance
        </p>
        <RollingNumber
          :value="current.balance"
          :locale="props.locale"
          :format="{ style: 'currency', currency: props.currency }"
          class="text-3xl font-semibold tracking-tight"
        />
      </div>
      <p class="m-0 shrink-0 text-xs text-muted-foreground">
        {{ flipped ? 'Tap card to hide' : 'Tap card for details' }}
      </p>
    </div>

    <!-- Transactions for the active card -->
    <div class="relative mt-3 min-h-16">
      <AnimatePresence mode="wait" :initial="false">
        <motion.ul
          v-if="current"
          :key="current.id"
          :aria-label="`Recent ${current.label} transactions`"
          class="m-0 flex list-none flex-col gap-1 p-0"
          :exit="{ opacity: 0, y: -8, transition: { duration: reduced ? 0 : 0.12 } }"
        >
          <motion.li
            v-for="(tx, i) in current.transactions ?? []"
            :key="tx.id"
            class="flex items-center gap-3 rounded-xl px-2 py-2"
            :initial="{ opacity: 0, y: -14 }"
            :animate="{ opacity: 1, y: 0, transition: { ...listSpring, delay: reduced ? 0 : 0.05 + i * 0.05 } }"
          >
            <slot name="transaction" :transaction="tx">
              <span class="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-semibold text-muted-foreground" aria-hidden="true">
                {{ tx.merchant.charAt(0) }}
              </span>
              <span class="flex min-w-0 flex-1 flex-col">
                <span class="truncate text-sm font-medium">{{ tx.merchant }}</span>
                <span class="text-xs text-muted-foreground">{{ tx.date }}</span>
              </span>
              <span :class="cn('text-sm font-medium tabular-nums', tx.amount < 0 && 'text-muted-foreground')">
                {{ signed(tx.amount) }}
              </span>
            </slot>
          </motion.li>
          <li v-if="!current.transactions?.length" class="px-2 py-4 text-sm text-muted-foreground">
            No recent transactions
          </li>
        </motion.ul>
      </AnimatePresence>
    </div>
  </div>
</template>
vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { motion, useReducedMotion } from 'motion-v'
import { computed } from 'vue'
import { cn } from '@/lib/utils'

// Odometer-style number: every digit is a 0–9 column that springs to its new place.
const props = defineProps<{
  value: number
  locale?: string
  format?: Intl.NumberFormatOptions
  class?: HTMLAttributes['class']
}>()

const reduced = useReducedMotion()
const roll = { type: 'spring', stiffness: 180, damping: 22, mass: 0.7 } as const

const text = computed(() => new Intl.NumberFormat(props.locale, props.format).format(props.value))
// Keyed from the right so units stay units when the number gains a digit.
const chars = computed(() => {
  const all = [...text.value]
  return all.map((char, i) => ({ char, key: all.length - i, digit: /\d/.test(char) ? Number(char) : null }))
})
</script>

<template>
  <span :class="cn('inline-flex', props.class)">
    <span class="sr-only">{{ text }}</span>
    <span aria-hidden="true" class="inline-flex">
      <template v-for="c in chars" :key="c.key">
        <span v-if="c.digit === null" class="inline-block h-[1.15em] whitespace-pre leading-[1.15]">{{ c.char }}</span>
        <!-- The invisible copy sizes the slot to the current digit; the column rolls inside it. -->
        <span v-else class="relative inline-block h-[1.15em] overflow-hidden leading-[1.15]">
          <span class="invisible">{{ c.char }}</span>
          <motion.span
            class="absolute inset-x-0 top-0 flex flex-col items-center"
            :initial="false"
            :animate="{ y: `${-c.digit * 10}%` }"
            :transition="reduced ? { duration: 0 } : roll"
          >
            <span v-for="n in 10" :key="n" class="block h-[1.15em]">{{ n - 1 }}</span>
          </motion.span>
        </span>
      </template>
    </span>
  </span>
</template>
ts
export { default as WalletCard } from './WalletCard.vue'
export type { WalletCardItem, WalletTransaction } from './WalletCard.vue'

Usage ​

ts
import { WalletCard, type WalletCardItem } from '@/components/ui/wallet-card'
vue
<WalletCard
  v-model:active="active"
  :cards="[{
    id: 'everyday',
    label: 'Everyday',
    brand: 'VISA',
    number: '4929 1840 3302 7741',
    holder: 'Mara Lindqvist',
    expiry: '09/29',
    cvv: '381',
    balance: 4821.36,
    background: 'linear-gradient(135deg, #0f172a, #334155)',
    foreground: '#f8fafc',
    transactions: [{ id: 't1', merchant: 'Blue Bottle Coffee', amount: -6.5, date: 'Today' }],
  }]"
/>

Click a card peeking out of the stack (or press the arrow keys) to pull it out and drop it in front; click the front card to flip it and reveal the full number and CVV (Escape flips it back). The balance rolls to the new card's value and its transactions slide in from under the card.

API ​

PropTypeDefaultDescription
cardsWalletCardItem[]—See below.
v-model:activestringfirst cardId of the card in front.
currencystring'USD'Currency for the balance and transactions.
localestring—Number formatting locale.
peeknumber16Pixels each card behind the front one peeks out.

WalletCardItem: { id, label, brand, number, holder, expiry, cvv, balance, background?, foreground?, transactions? }. background is any CSS background (color or gradient) and foreground its text color; without them the card uses the primary token. Transactions are { id, merchant, amount, date } — negative amounts are spending.

SlotPropsDescription
transaction{ transaction }Custom content for a transaction row.

Designed by Kuldeep Pisda · Released under the MIT License