Virtual Card Manager
blockfinanceMercury/Ramp-style corporate virtual card manager: interactive dark credit card visual hero with EMV chip, revealable credentials, live spending limit progress bar, instant freeze toggles, limit adjusters, and an active virtual cards table.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/virtual-card-manager.json$npx shadcn@latest add https://uipkge.dev/r/react/virtual-card-manager.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/virtual-card-manager.json$bunx shadcn@latest add https://uipkge.dev/r/react/virtual-card-manager.jsonNamed registry:
npx shadcn@latest add @uipkge-react/virtual-card-managerInstalls to:components/blocks/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
className | string | — | optional |
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
VirtualCardinterface VirtualCard {
id: string
name: string
brand: 'mastercard' | 'visa'
last4: string
fullNumber: string
holder: string
avatar: string
initials: string
expiry: string
cvv: string
spent: number
limit: number
type: 'Monthly Recurring' | 'Single-use'
status: 'Active' | 'Frozen'
billingCycle: string
purpose: string
}Files installed (1)
components/blocks/VirtualCardManager.tsx25.8 kB
'use client' import * as React from 'react' import { Check, Copy, CreditCard, Eye, EyeOff, Plus, Radio, ShieldCheck, SlidersHorizontal, Snowflake, CheckCircle2, Trash2, Zap, } from 'lucide-react' import { cn } from '@/lib/utils' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Progress } from '@/components/ui/progress' import { Switch } from '@/components/ui/switch' interface VirtualCard { id: string name: string brand: 'mastercard' | 'visa' last4: string fullNumber: string holder: string avatar: string initials: string expiry: string cvv: string spent: number limit: number type: 'Monthly Recurring' | 'Single-use' status: 'Active' | 'Frozen' billingCycle: string purpose: string } const initialCards: VirtualCard[] = [ { id: 'vc-1', name: 'AWS & Cloud Infrastructure', brand: 'mastercard', last4: '8492', fullNumber: '5532 8920 4108 8492', holder: 'Elena Rostova', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&auto=format&fit=crop&q=80', initials: 'ER', expiry: '08/29', cvv: '482', spent: 4200, limit: 5000, type: 'Monthly Recurring', status: 'Active', billingCycle: 'Resets on 1st of month', purpose: 'Cloud compute, S3 buckets, and RDS instances', }, { id: 'vc-2', name: 'Marketing & Ad Spend', brand: 'visa', last4: '3190', fullNumber: '4012 8831 9204 3190', holder: 'Marcus Chen', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&auto=format&fit=crop&q=80', initials: 'MC', expiry: '11/28', cvv: '915', spent: 8750, limit: 10000, type: 'Monthly Recurring', status: 'Active', billingCycle: 'Resets on 1st of month', purpose: 'Google Ads, LinkedIn campaigns, and Meta Ads', }, { id: 'vc-3', name: 'Travel & Conferences', brand: 'visa', last4: '6021', fullNumber: '4111 5900 1284 6021', holder: 'Sarah Jenkins', avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&auto=format&fit=crop&q=80', initials: 'SJ', expiry: '03/27', cvv: '304', spent: 1850, limit: 2500, type: 'Single-use', status: 'Frozen', billingCycle: 'Single event allowance', purpose: 'Q3 Design Systems Summit flights and lodging', }, { id: 'vc-4', name: 'SaaS Subscriptions', brand: 'mastercard', last4: '9455', fullNumber: '5241 7719 3302 9455', holder: 'Elena Rostova', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&auto=format&fit=crop&q=80', initials: 'ER', expiry: '05/29', cvv: '729', spent: 620, limit: 1200, type: 'Monthly Recurring', status: 'Active', billingCycle: 'Resets on 1st of month', purpose: 'GitHub Enterprise, Figma, and Slack licenses', }, ] export interface VirtualCardManagerProps { className?: string } export function VirtualCardManager({ className }: VirtualCardManagerProps) { const [cards, setCards] = React.useState<VirtualCard[]>(initialCards) const [selectedId, setSelectedId] = React.useState<string>('vc-1') const [showDetails, setShowDetails] = React.useState<boolean>(false) const [copied, setCopied] = React.useState<boolean>(false) const [actionFeedback, setActionFeedback] = React.useState<string | null>(null) const selectedCard = cards.find((c) => c.id === selectedId) ?? cards[0] const isSelectedFrozen = selectedCard?.status === 'Frozen' const selectedPercent = selectedCard && selectedCard.limit > 0 ? Math.min(100, Math.round((selectedCard.spent / selectedCard.limit) * 100)) : 0 const showFeedback = React.useCallback((msg: string) => { setActionFeedback(msg) setTimeout(() => { setActionFeedback((prev) => (prev === msg ? null : prev)) }, 3000) }, []) const toggleFreeze = React.useCallback((id: string) => { setCards((prev) => prev.map((c) => { if (c.id === id) { const nextStatus = c.status === 'Active' ? 'Frozen' : 'Active' return { ...c, status: nextStatus } } return c }), ) }, []) const handleCopyNumber = React.useCallback(() => { if (!selectedCard) return if (typeof navigator !== 'undefined' && navigator.clipboard) { navigator.clipboard.writeText(selectedCard.fullNumber.replace(/\s+/g, '')) } setCopied(true) setTimeout(() => setCopied(false), 2000) }, [selectedCard]) const adjustLimit = React.useCallback( (cardId: string) => { setCards((prev) => prev.map((c) => { if (c.id === cardId) { const newLimit = c.limit === 5000 ? 7500 : c.limit === 10000 ? 12000 : c.limit + 1000 return { ...c, limit: newLimit } } return c }), ) const current = cards.find((c) => c.id === cardId) const newLimit = current ? current.limit === 5000 ? 7500 : current.limit === 10000 ? 12000 : current.limit + 1000 : 0 showFeedback(`Limit updated to $${newLimit.toLocaleString()}`) }, [cards, showFeedback], ) const terminateCard = React.useCallback( (cardId: string) => { if (cards.length <= 1) { showFeedback('Cannot terminate the last remaining card') return } const remaining = cards.filter((c) => c.id !== cardId) setCards(remaining) if (selectedId === cardId) { setSelectedId(remaining[0].id) } showFeedback('Card terminated permanently') }, [cards, selectedId, showFeedback], ) const issueNewCard = React.useCallback(() => { const newId = `vc-${Date.now().toString().slice(-4)}` const newLast4 = Math.floor(1000 + Math.random() * 9000).toString() const newCard: VirtualCard = { id: newId, name: 'AI & API Workloads', brand: 'visa', last4: newLast4, fullNumber: `4400 1284 9912 ${newLast4}`, holder: 'Elena Rostova', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&auto=format&fit=crop&q=80', initials: 'ER', expiry: '09/30', cvv: '814', spent: 0, limit: 3000, type: 'Monthly Recurring', status: 'Active', billingCycle: 'Resets on 1st of month', purpose: 'OpenAI, Anthropic, and Vector DB infrastructure', } setCards((prev) => [newCard, ...prev]) setSelectedId(newId) showFeedback('New virtual card issued successfully') }, [showFeedback]) return ( <div data-slot="virtual-card-manager" className={cn('mx-auto w-full max-w-6xl space-y-6', className)}> {/* Header */} <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="space-y-1"> <div className="flex items-center gap-2.5"> <h2 className="text-foreground text-xl font-semibold tracking-tight sm:text-2xl">Virtual Cards</h2> <Badge wrap variant="secondary" className="font-mono text-xs tabular-nums"> {cards.length} Cards </Badge> </div> <p className="text-muted-foreground text-sm"> Generate instant corporate cards for subscriptions, vendors, and team expense limits. </p> </div> <div className="flex items-center gap-2"> <Button size="sm" className="gap-1.5 shadow-xs" onClick={issueNewCard}> <Plus className="size-4" /> Issue New Card </Button> </div> </div> {/* Notification / Action feedback */} {actionFeedback && ( <div className="bg-primary/10 text-primary border-primary/20 flex items-center justify-between rounded-lg border px-4 py-2.5 text-xs font-medium transition-colors"> <div className="flex items-center gap-2"> <CheckCircle2 className="size-4 shrink-0" /> <span>{actionFeedback}</span> </div> <Button aria-label="Dismiss notification" variant="ghost" size="xs" className="h-6 px-2 text-xs" onClick={() => setActionFeedback(null)} > Dismiss </Button> </div> )} {/* Virtual Card Visual Hero */} <Card className="border-border bg-card shadow-xs"> <CardHeader className="border-b pb-4"> <div className="flex flex-wrap items-center justify-between gap-2"> <div> <div className="flex items-center gap-2"> <CardTitle className="text-base font-semibold">{selectedCard.name}</CardTitle> <Badge wrap variant={selectedCard.status === 'Active' ? 'success' : 'warning'} className="text-xs"> {selectedCard.status} </Badge> </div> <CardDescription className="mt-0.5 text-xs"> {selectedCard.purpose} · {selectedCard.billingCycle} </CardDescription> </div> <Badge wrap variant="outline" className="font-mono text-xs"> Ending in {selectedCard.last4} </Badge> </div> </CardHeader> <CardContent className="pt-6"> <div className="grid gap-6 lg:grid-cols-12 lg:items-center"> {/* Stylized Dark Credit Card Container */} <div className="lg:col-span-6 xl:col-span-5"> <div className={cn( 'relative aspect-[1.586/1] w-full rounded-2xl p-6 text-white transition-[filter] duration-300 lg:max-w-[420px]', 'border border-zinc-700/60 bg-gradient-to-br from-zinc-900 via-neutral-900 to-zinc-950 shadow-xl', isSelectedFrozen && 'contrast-95 grayscale filter', )} > {/* Ambient light accents */} <div className="bg-chart-1/15 pointer-events-none absolute -top-16 -right-16 size-48 rounded-full blur-2xl" aria-hidden="true" /> <div className="bg-success/10 pointer-events-none absolute -bottom-16 -left-16 size-48 rounded-full blur-2xl" aria-hidden="true" /> {/* Frozen watermark overlay */} {isSelectedFrozen && ( <div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 rounded-2xl bg-zinc-950/70 backdrop-blur-xs"> <div className="border-warning/40 bg-warning/20 text-warning inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-semibold tracking-wider uppercase"> <Snowflake className="size-3.5" /> Card Frozen </div> <p className="text-muted-foreground text-xs">Transactions are currently blocked</p> </div> )} {/* Top Row: Chip & Contactless + Brand Logo */} <div className="relative z-10 flex items-center justify-between"> <div className="flex items-center gap-3"> {/* Metallic EMV Chip */} <div className="border-warning/20/40 relative flex h-8 w-11 items-center justify-center rounded-md border bg-gradient-to-br from-amber-200/30 via-amber-400/20 to-amber-600/30 shadow-inner" aria-hidden="true" > <div className="border-warning/20/30 absolute inset-0 grid grid-cols-3 grid-rows-2"> <div className="border-warning/30 border-r border-b" /> <div className="border-warning/30 border-r border-b" /> <div className="border-warning/30 border-b" /> <div className="border-warning/30 border-r" /> <div className="border-warning/30 border-r" /> <div /> </div> </div> {/* NFC Wave icon */} <div className="text-muted-foreground/80 rotate-90" aria-hidden="true"> <Radio className="size-4" /> </div> </div> {/* Brand Indicator */} <div className="flex items-center gap-2"> <span className="text-muted-foreground font-mono text-xs font-medium tracking-wider uppercase"> Virtual </span> {selectedCard.brand === 'mastercard' ? ( <div className="flex -space-x-2" aria-label="Mastercard"> <div className="bg-destructive/90 size-6 rounded-full" /> <div className="bg-warning/90 size-6 rounded-full mix-blend-screen" /> </div> ) : ( <div className="text-base font-bold tracking-widest text-zinc-100 italic" aria-label="Visa"> VISA </div> )} </div> </div> {/* Middle Row: Card Number & Quick Actions */} <div className="relative z-10 my-auto pt-4"> <div className="flex items-center justify-between gap-2"> <p className="font-mono text-lg font-medium tracking-[0.16em] text-zinc-100 tabular-nums drop-shadow-sm sm:text-xl"> {showDetails ? selectedCard.fullNumber : `•••• •••• •••• ${selectedCard.last4}`} </p> <div className="flex items-center gap-1"> <Button variant="ghost" size="icon-sm" className="size-7 text-zinc-300 hover:bg-white/10 hover:text-white" aria-label={showDetails ? 'Hide card number' : 'Reveal card number'} onClick={() => setShowDetails(!showDetails)} > {showDetails ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />} </Button> <Button variant="ghost" size="icon-sm" className="size-7 text-zinc-300 hover:bg-white/10 hover:text-white" aria-label="Copy card number" onClick={handleCopyNumber} > {copied ? <Check className="text-success size-3.5" /> : <Copy className="size-3.5" />} </Button> </div> </div> </div> {/* Bottom Row: Cardholder, Expiry, CVV */} <div className="relative z-10 flex items-end justify-between pt-2"> <div className="space-y-0.5"> <p className="text-muted-foreground font-mono text-xs tracking-wider uppercase">Cardholder</p> <p className="max-w-[140px] truncate text-xs font-semibold tracking-wide text-zinc-200 uppercase"> {selectedCard.holder} </p> </div> <div className="space-y-0.5"> <p className="text-muted-foreground font-mono text-xs tracking-wider uppercase">Expires</p> <p className="font-mono text-xs font-medium text-zinc-200 tabular-nums">{selectedCard.expiry}</p> </div> <div className="space-y-0.5 text-right"> <p className="text-muted-foreground font-mono text-xs tracking-wider uppercase">CVV</p> <p className="font-mono text-xs font-medium text-zinc-200 tabular-nums"> {showDetails ? selectedCard.cvv : '•••'} </p> </div> </div> </div> </div> {/* Card Controls & Spending Breakdown */} <div className="space-y-6 lg:col-span-6 xl:col-span-7"> {/* Spending Limit Meter */} <div className="border-border bg-muted/30 space-y-3 rounded-lg border p-4"> <div className="flex items-center justify-between"> <span className="text-sm font-medium">Monthly Spending Limit</span> <span className="text-muted-foreground font-mono text-xs tabular-nums"> ${selectedCard.spent.toLocaleString()} / ${selectedCard.limit.toLocaleString()} </span> </div> <Progress value={selectedPercent} className="h-2" /> <div className="text-muted-foreground flex items-center justify-between text-xs"> <span className="text-foreground font-medium tabular-nums"> ${Math.max(0, selectedCard.limit - selectedCard.spent).toLocaleString()} available </span> <span className="font-medium tabular-nums">{selectedPercent}% utilized</span> </div> </div> {/* Card Controls Row */} <div className="grid gap-3 sm:grid-cols-3"> {/* Freeze Card Toggle */} <div className="border-border bg-card flex items-center justify-between rounded-lg border p-3 sm:flex-col sm:items-start sm:justify-between sm:gap-2"> <div className="space-y-0.5"> <p className="text-foreground text-xs font-medium">Freeze Card</p> <p className="text-muted-foreground text-xs">Block charges</p> </div> <Switch checked={isSelectedFrozen} size="sm" aria-label="Toggle card freeze status" onCheckedChange={() => toggleFreeze(selectedCard.id)} /> </div> {/* Change Limit Button */} <Button variant="outline" size="sm" className="h-auto flex-col items-start gap-1 p-3 text-left" onClick={() => adjustLimit(selectedCard.id)} > <div className="flex w-full items-center justify-between"> <span className="text-foreground text-xs font-medium">Adjust Limit</span> <SlidersHorizontal className="text-muted-foreground size-3.5" /> </div> <span className="text-muted-foreground text-xs font-normal">Edit monthly cap</span> </Button> {/* Terminate Card Button */} <Button variant="outline" size="sm" className="text-destructive hover:text-destructive hover:bg-destructive/10 border-border h-auto flex-col items-start gap-1 p-3 text-left" onClick={() => terminateCard(selectedCard.id)} > <div className="flex w-full items-center justify-between"> <span className="text-xs font-medium">Terminate</span> <Trash2 className="size-3.5" /> </div> <span className="text-muted-foreground text-xs font-normal">Close permanently</span> </Button> </div> {/* Card Meta Specs */} <div className="grid grid-cols-2 gap-3 pt-1 sm:grid-cols-3"> <div className="space-y-0.5"> <span className="text-muted-foreground text-xs">Cardholder</span> <div className="flex items-center gap-1.5 pt-0.5"> <Avatar size="xs" className="size-4.5"> <AvatarImage src={selectedCard.avatar} alt={selectedCard.holder} /> <AvatarFallback className="text-xs">{selectedCard.initials}</AvatarFallback> </Avatar> <span className="truncate text-xs font-medium">{selectedCard.holder}</span> </div> </div> <div className="space-y-0.5"> <span className="text-muted-foreground text-xs">Card Type</span> <p className="text-foreground text-xs font-medium">{selectedCard.type}</p> </div> <div className="space-y-0.5"> <span className="text-muted-foreground text-xs">Fraud Protection</span> <div className="text-success flex items-center gap-1 text-xs font-medium"> <ShieldCheck className="size-3.5" /> <span>3D Secure Active</span> </div> </div> </div> </div> </div> </CardContent> </Card> {/* Active Cards Grid / Table */} <Card className="border-border bg-card shadow-xs"> <CardHeader className="border-b pb-4"> <div className="flex items-center justify-between"> <div> <CardTitle className="text-base font-semibold">Active Cards List</CardTitle> <CardDescription className="mt-0.5 text-xs"> All company virtual cards currently assigned to team members and services. </CardDescription> </div> <Badge wrap variant="outline" className="font-mono text-xs tabular-nums"> {cards.length} Issued </Badge> </div> </CardHeader> <CardContent className="p-0"> <div className="divide-border divide-y"> {cards.map((card) => ( <div key={card.id} className={cn( 'group flex flex-col gap-4 p-4 transition-colors sm:flex-row sm:items-center sm:justify-between', selectedId === card.id ? 'bg-muted/40' : 'hover:bg-muted/20', )} > {/* Card Identity & Number */} <div className="flex min-w-0 items-center gap-3.5 sm:w-1/3"> <div className={cn( 'border-border flex size-10 shrink-0 items-center justify-center rounded-lg border shadow-xs transition-transform', card.status === 'Frozen' ? 'bg-muted/60 text-muted-foreground' : 'bg-primary/10 text-primary', )} > <CreditCard className="size-5" /> </div> <div className="min-w-0 flex-1 space-y-0.5"> <div className="flex items-center gap-2"> <p className="text-foreground truncate text-sm font-medium">{card.name}</p> </div> <div className="text-muted-foreground flex items-center gap-2 text-xs"> <span className="font-mono tabular-nums">•••• {card.last4}</span> <span>·</span> <span className="uppercase">{card.brand}</span> </div> </div> </div> {/* Cardholder Avatar & Name */} <div className="flex items-center gap-2 sm:w-1/5"> <Avatar size="sm"> <AvatarImage src={card.avatar} alt={card.holder} /> <AvatarFallback>{card.initials}</AvatarFallback> </Avatar> <div className="min-w-0"> <p className="text-foreground truncate text-xs font-medium">{card.holder}</p> <p className="text-muted-foreground truncate text-xs">{card.type}</p> </div> </div> {/* Spending Limit & Progress */} <div className="space-y-1.5 sm:w-1/4"> <div className="flex items-center justify-between text-xs"> <span className="text-foreground font-mono font-medium tabular-nums"> ${card.spent.toLocaleString()} / ${card.limit.toLocaleString()} </span> <span className="text-muted-foreground font-mono tabular-nums"> {Math.round((card.spent / card.limit) * 100)}% </span> </div> <Progress value={Math.min(100, Math.round((card.spent / card.limit) * 100))} className="h-1.5" /> </div> {/* Status Badge & Actions */} <div className="flex items-center justify-between gap-3 sm:justify-end"> <Badge wrap variant={card.status === 'Active' ? 'success' : 'warning'} className="text-xs"> {card.status} </Badge> <div className="flex items-center gap-1.5"> <Button variant={selectedId === card.id ? 'secondary' : 'ghost'} size="sm" className="text-xs" onClick={() => setSelectedId(card.id)} > {selectedId === card.id ? 'Viewing' : 'View Card'} </Button> <Button variant="ghost" size="icon-sm" aria-label={card.status === 'Active' ? 'Freeze card' : 'Unfreeze card'} onClick={() => toggleFreeze(card.id)} > {card.status === 'Active' ? ( <Snowflake className="text-muted-foreground size-4" /> ) : ( <Zap className="text-warning size-4" /> )} </Button> </div> </div> </div> ))} </div> </CardContent> </Card> </div> ) }
Raw manifest:https://uipkge.dev/r/react/virtual-card-manager.json