Dynamic Checkout Flow
A seamless, token-aware multi-step checkout flow with animated transitions, an interactive card, and success celebration.
Preview
Live interactive preview.
Installation
Add this component to your project using the CLI:
npx -y vui-registry-cli-v1@latest add dynamic-checkoutcard-input-utils.ts
export function digitsOnly(value: string) {
return value.replace(/\D/g, '')
}
export function formatCardNumber(value: string) {
const digits = digitsOnly(value).slice(0, 16)
return digits.replace(/(\d{4})(?=\d)/g, '$1 ').trim()
}
export function formatExpiry(value: string) {
const digits = digitsOnly(value).slice(0, 4)
if (digits.length <= 2) return digits
return `${digits.slice(0, 2)}/${digits.slice(2)}`
}
export function formatCvv(value: string) {
return digitsOnly(value).slice(0, 3)
}
export function formatZip(value: string) {
return digitsOnly(value).slice(0, 6)
}
export function displayCardNumber(cardNumber: string) {
const digits = digitsOnly(cardNumber)
if (!digits) return '4242 4242 4242 4242'
return formatCardNumber(digits).padEnd(19, '•')
}
Source Code
'use client'
import { VelocityLogo } from '@/components/velocity-logo'
export function CheckoutLogo() {
return (
<div className="flex items-center gap-2.5">
<VelocityLogo height={22} iconOnly />
<div>
<span className="font-bold text-sm tracking-tight text-neutral-900 dark:text-white block leading-none">
Velocity Pay
</span>
<span className="text-[9px] text-neutral-500 font-medium tracking-wider uppercase">
Secure Checkout
</span>
</div>
</div>
)
}
Source Code
'use client'
import React, { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
// @ts-ignore
import confetti from 'canvas-confetti'
import { CheckoutLogo } from './checkout-logo'
import { ShippingStep } from './shipping-step'
import { PaymentStep } from './payment-step'
import { ReviewStep } from './review-step'
import { SuccessStep } from './success-step'
import { INITIAL_FORM_DATA, type FormData, type Step } from './types'
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 20 : -20,
opacity: 0,
scale: 0.98,
filter: 'blur(4px)',
}),
center: {
x: 0,
opacity: 1,
scale: 1,
filter: 'blur(0px)',
},
exit: (direction: number) => ({
x: direction > 0 ? -20 : 20,
opacity: 0,
scale: 0.98,
filter: 'blur(4px)',
}),
}
export default function DynamicCheckout() {
const [step, setStep] = useState<Step>('shipping')
const [direction, setDirection] = useState(1)
const [loading, setLoading] = useState(false)
const [focusedField, setFocusedField] = useState<string | null>(null)
const [formData, setFormData] = useState<FormData>(INITIAL_FORM_DATA)
const patchForm = (patch: Partial<FormData>) => {
setFormData((prev) => ({ ...prev, ...patch }))
}
const handleNext = () => {
setDirection(1)
if (step === 'shipping') setStep('payment')
else if (step === 'payment') setStep('review')
else if (step === 'review') handleSubmit()
}
const handleBack = () => {
setDirection(-1)
if (step === 'payment') setStep('shipping')
else if (step === 'review') setStep('payment')
}
const handleSubmit = () => {
setLoading(true)
setTimeout(() => {
setLoading(false)
setStep('success')
confetti({
particleCount: 150,
spread: 80,
origin: { y: 0.6 },
colors: ['#FFD700', '#FFA500', '#FF4500'],
})
}, 1500)
}
const handleReset = () => {
setStep('shipping')
setFormData(INITIAL_FORM_DATA)
setFocusedField(null)
}
return (
<div className="min-h-[600px] w-full flex items-center justify-center bg-transparent p-4 font-sans relative overflow-hidden">
<motion.div
layout
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
className="bg-white dark:bg-[#0a0a0a] rounded-2xl shadow-2xl shadow-black/10 dark:shadow-black/50 border border-neutral-200/50 dark:border-white/10 w-full max-w-[360px] overflow-hidden relative z-10"
>
<div className="px-5 pt-5 pb-3 flex items-center justify-between border-b border-neutral-100 dark:border-white/5">
<CheckoutLogo />
{step !== 'success' && (
<div className="flex gap-1">
{(['shipping', 'payment', 'review'] as const).map((s, i) => {
const currentIndex = ['shipping', 'payment', 'review'].indexOf(step)
const isActive = currentIndex >= i
return (
<motion.div
key={s}
initial={false}
animate={{
width: currentIndex === i ? 16 : 4,
opacity: isActive ? 1 : 0.3,
}}
className="h-1 rounded-full bg-neutral-900 dark:bg-white"
/>
)
})}
</div>
)}
</div>
<div className="p-5">
<AnimatePresence mode="wait" custom={direction}>
{step === 'shipping' && (
<motion.div key="shipping" custom={direction} variants={variants} initial="enter" animate="center" exit="exit">
<ShippingStep formData={formData} onChange={patchForm} onNext={handleNext} />
</motion.div>
)}
{step === 'payment' && (
<motion.div key="payment" custom={direction} variants={variants} initial="enter" animate="center" exit="exit">
<PaymentStep
formData={formData}
focusedField={focusedField}
onChange={patchForm}
onFocus={setFocusedField}
onBack={handleBack}
onNext={handleNext}
/>
</motion.div>
)}
{step === 'review' && (
<motion.div key="review" custom={direction} variants={variants} initial="enter" animate="center" exit="exit">
<ReviewStep formData={formData} loading={loading} onBack={handleBack} onSubmit={handleSubmit} />
</motion.div>
)}
{step === 'success' && <SuccessStep onReset={handleReset} />}
</AnimatePresence>
</div>
</motion.div>
</div>
)
}
Source Code
'use client'
import { motion } from 'framer-motion'
import { Wifi } from 'lucide-react'
import { displayCardNumber } from './card-input-utils'
import type { FormData } from './types'
type PaymentCard3DProps = {
formData: FormData
focusedField: string | null
onToggleCvv: () => void
}
export function PaymentCard3D({ formData, focusedField, onToggleCvv }: PaymentCard3DProps) {
return (
<div
className="w-full aspect-[1.586] perspective-1000 group cursor-pointer"
onClick={onToggleCvv}
>
<motion.div
className="w-full h-full relative preserve-3d transition-all duration-700"
animate={{ rotateY: focusedField === 'cvv' ? 180 : 0 }}
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
>
<div className="absolute inset-0 backface-hidden rounded-xl p-4 text-white shadow-2xl flex flex-col justify-between overflow-hidden border border-white/10 bg-[#0a0a0a]">
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-30 mix-blend-overlay" />
<div className="absolute inset-0 bg-gradient-to-br from-neutral-900 via-[#1a1a1a] to-neutral-900" />
<div className="absolute inset-0 overflow-hidden">
<motion.div
animate={{ x: ['-100%', '200%'], opacity: [0, 0.5, 0] }}
transition={{ duration: 2.5, repeat: Infinity, repeatDelay: 1, ease: 'easeInOut' }}
className="absolute top-0 bottom-0 w-32 bg-gradient-to-r from-transparent via-white/10 to-transparent skew-x-12 blur-xl"
/>
</div>
<div className="flex justify-between items-start z-10 relative">
<div className="w-9 h-6 rounded bg-gradient-to-br from-[#e0c388] via-[#d4af37] to-[#b8860b] border border-[#d4af37]/50 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-white/40 to-transparent opacity-50" />
</div>
<Wifi className="w-3.5 h-3.5 text-white/70 rotate-90" />
</div>
<div className="z-10 relative mt-1">
<p className="font-mono text-[15px] tracking-[0.14em] text-white/90 drop-shadow-md">
{displayCardNumber(formData.cardNumber)}
</p>
<div className="flex items-end gap-8 mt-2">
<div>
<p className="text-[6px] text-white/50 uppercase tracking-widest mb-0.5 font-semibold">Card Holder</p>
<p className="font-medium tracking-wide uppercase text-[9px] text-white/90">
{formData.name || 'YOUR NAME'}
</p>
</div>
<div>
<p className="text-[6px] text-white/50 uppercase tracking-widest mb-0.5 font-semibold">Expires</p>
<p className="font-medium tracking-wide text-[9px] text-white/90">
{formData.expiry || 'MM/YY'}
</p>
</div>
</div>
</div>
<div className="absolute bottom-4 right-4 z-10">
<span className="font-black italic tracking-wider text-white text-lg drop-shadow-md opacity-90">VISA</span>
</div>
</div>
<div
className="absolute inset-0 backface-hidden bg-[#0a0a0a] rounded-xl text-white shadow-xl overflow-hidden border border-white/10"
style={{ transform: 'rotateY(180deg)' }}
>
<div className="w-full h-8 bg-black mt-4" />
<div className="p-4">
<p className="text-[7px] text-white/60 uppercase tracking-wider mb-1 mr-1 text-right">CVV</p>
<div className="w-full h-7 bg-white text-black font-mono flex items-center justify-end px-2 rounded text-xs font-bold">
{formData.cvv || '123'}
</div>
</div>
</div>
</motion.div>
</div>
)
}
Source Code
'use client'
import { motion } from 'framer-motion'
import { ChevronLeft, ChevronRight, CreditCard } from 'lucide-react'
import { formatCardNumber, formatCvv, formatExpiry } from './card-input-utils'
import { PaymentCard3D } from './payment-card-3d'
import type { FormData } from './types'
type PaymentStepProps = {
formData: FormData
focusedField: string | null
onChange: (patch: Partial<FormData>) => void
onFocus: (field: string | null) => void
onBack: () => void
onNext: () => void
}
export function PaymentStep({
formData,
focusedField,
onChange,
onFocus,
onBack,
onNext,
}: PaymentStepProps) {
return (
<motion.div className="space-y-4" initial={false}>
<div className="mb-1">
<h2 className="text-lg font-bold text-neutral-900 dark:text-white">Payment</h2>
<p className="text-[10px] text-neutral-500 mt-0.5">Secure credit card transaction.</p>
</div>
<PaymentCard3D
formData={formData}
focusedField={focusedField}
onToggleCvv={() => onFocus(focusedField === 'cvv' ? 'cardNumber' : 'cvv')}
/>
<div className="space-y-2.5 p-1">
<div>
<label className="text-[9px] font-bold text-neutral-500 uppercase tracking-wider mb-1 block">Card Number</label>
<div className="relative bg-white dark:bg-neutral-900 rounded-lg border border-neutral-200 dark:border-neutral-700 flex items-center">
<div className="pl-3 text-neutral-400">
<CreditCard className="w-3.5 h-3.5" />
</div>
<input
type="text"
inputMode="numeric"
placeholder="0000 0000 0000 0000"
maxLength={19}
className="w-full pl-2 pr-3 py-2 bg-transparent focus:outline-none font-mono text-xs"
value={formData.cardNumber}
onChange={(e) => onChange({ cardNumber: formatCardNumber(e.target.value) })}
onFocus={() => onFocus('cardNumber')}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2.5">
<div>
<label className="text-[9px] font-bold text-neutral-500 uppercase tracking-wider mb-1 block">Expiry Date</label>
<input
type="text"
inputMode="numeric"
placeholder="MM/YY"
maxLength={5}
className="w-full px-3 py-2 rounded-lg bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 focus:outline-none text-xs font-mono"
value={formData.expiry}
onChange={(e) => onChange({ expiry: formatExpiry(e.target.value) })}
onFocus={() => onFocus('expiry')}
/>
</div>
<div>
<label className="text-[9px] font-bold text-neutral-500 uppercase tracking-wider mb-1 block">CVV</label>
<input
type="text"
inputMode="numeric"
placeholder="123"
maxLength={3}
className="w-full px-3 py-2 rounded-lg bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 focus:outline-none text-xs font-mono"
value={formData.cvv}
onChange={(e) => onChange({ cvv: formatCvv(e.target.value) })}
onFocus={() => onFocus('cvv')}
/>
</div>
</div>
</div>
<div className="flex justify-between pt-2">
<button onClick={onBack} className="text-xs text-neutral-500 hover:text-neutral-900 dark:hover:text-white font-medium flex items-center gap-1">
<ChevronLeft className="w-3 h-3" /> Back
</button>
<button onClick={onNext} className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-neutral-950 dark:bg-white px-5 text-xs font-medium text-white dark:text-neutral-950">
Review <ChevronRight className="w-3 h-3" />
</button>
</div>
</motion.div>
)
}
Source Code
'use client'
import { motion } from 'framer-motion'
import { ChevronLeft, CreditCard, Loader2, MapPin, ShieldCheck } from 'lucide-react'
import { digitsOnly } from './card-input-utils'
import type { FormData } from './types'
type ReviewStepProps = {
formData: FormData
loading: boolean
onBack: () => void
onSubmit: () => void
}
export function ReviewStep({ formData, loading, onBack, onSubmit }: ReviewStepProps) {
const lastFour = digitsOnly(formData.cardNumber).slice(-4) || '4242'
return (
<motion.div className="space-y-4" initial={false}>
<div className="mb-1">
<h2 className="text-lg font-bold text-neutral-900 dark:text-white">Review</h2>
<p className="text-[10px] text-neutral-500 mt-0.5">Please verify your information.</p>
</div>
<div className="space-y-3">
<div className="bg-neutral-50 dark:bg-neutral-800/30 rounded-xl p-3 border border-neutral-100 dark:border-white/5 space-y-3">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center shrink-0">
<MapPin className="w-4 h-4 text-blue-500" />
</div>
<div>
<p className="text-[10px] text-neutral-500 font-bold uppercase tracking-wider mb-0.5">Shipping To</p>
<p className="text-xs font-medium text-neutral-900 dark:text-white">{formData.name || 'John Doe'}</p>
<p className="text-xs text-neutral-500">
{formData.address || '123 Innovation Dr'}, {formData.city || 'San Francisco'} {formData.zip || '94103'}
</p>
</div>
</div>
<div className="h-px bg-neutral-200 dark:bg-white/5 w-full" />
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-purple-500/10 flex items-center justify-center shrink-0">
<CreditCard className="w-4 h-4 text-purple-500" />
</div>
<div>
<p className="text-[10px] text-neutral-500 font-bold uppercase tracking-wider mb-0.5">Payment Method</p>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-neutral-900 dark:text-white">Visa ending in {lastFour}</span>
<div className="px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-[9px] font-mono">
{formData.expiry || '12/25'}
</div>
</div>
</div>
</div>
</div>
<div className="bg-neutral-50 dark:bg-neutral-800/30 rounded-xl p-3 border border-neutral-100 dark:border-white/5">
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs text-neutral-500">Subtotal</span>
<span className="text-xs font-medium text-neutral-900 dark:text-white">$129.00</span>
</div>
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs text-neutral-500">Shipping</span>
<span className="text-xs font-medium text-green-600">Free</span>
</div>
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs text-neutral-500">Tax</span>
<span className="text-xs font-medium text-neutral-900 dark:text-white">$12.90</span>
</div>
<div className="h-px bg-neutral-200 dark:bg-white/5 w-full my-2" />
<div className="flex justify-between items-center">
<span className="text-sm font-bold text-neutral-900 dark:text-white">Total</span>
<span className="text-sm font-bold text-neutral-900 dark:text-white">$141.90</span>
</div>
</div>
</div>
<div className="flex justify-between pt-2">
<button onClick={onBack} className="text-xs text-neutral-500 hover:text-neutral-900 dark:hover:text-white font-medium flex items-center gap-1">
<ChevronLeft className="w-3 h-3" /> Back
</button>
<button
onClick={onSubmit}
disabled={loading}
className="inline-flex h-9 min-w-[120px] items-center justify-center gap-1.5 rounded-lg bg-neutral-950 dark:bg-white px-6 text-xs font-medium text-white dark:text-neutral-950 disabled:opacity-70"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <>Pay $141.90 <ShieldCheck className="w-3 h-3" /></>}
</button>
</div>
</motion.div>
)
}
Source Code
'use client'
import { motion } from 'framer-motion'
import { ChevronRight } from 'lucide-react'
import { formatZip } from './card-input-utils'
import type { FormData } from './types'
type ShippingStepProps = {
formData: FormData
onChange: (patch: Partial<FormData>) => void
onNext: () => void
}
export function ShippingStep({ formData, onChange, onNext }: ShippingStepProps) {
return (
<motion.div className="space-y-3" initial={false}>
<div className="mb-1">
<h2 className="text-lg font-bold text-neutral-900 dark:text-white">Shipping</h2>
<p className="text-[10px] text-neutral-500 mt-0.5">Where should we send your order?</p>
</div>
<div className="space-y-2.5">
<div>
<label className="text-[9px] font-bold text-neutral-500 uppercase tracking-wider mb-1 block">Full Name</label>
<input
type="text"
placeholder="John Doe"
className="w-full px-3 py-2 rounded-lg bg-neutral-50 dark:bg-neutral-800/50 border border-neutral-200 dark:border-neutral-700 focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white text-xs"
value={formData.name}
onChange={(e) => onChange({ name: e.target.value })}
/>
</div>
<div>
<label className="text-[9px] font-bold text-neutral-500 uppercase tracking-wider mb-1 block">Address</label>
<input
type="text"
placeholder="123 Innovation Dr"
className="w-full px-3 py-2 rounded-lg bg-neutral-50 dark:bg-neutral-800/50 border border-neutral-200 dark:border-neutral-700 focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white text-xs"
value={formData.address}
onChange={(e) => onChange({ address: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-2.5">
<div>
<label className="text-[9px] font-bold text-neutral-500 uppercase tracking-wider mb-1 block">City</label>
<input
type="text"
placeholder="San Francisco"
className="w-full px-3 py-2 rounded-lg bg-neutral-50 dark:bg-neutral-800/50 border border-neutral-200 dark:border-neutral-700 focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white text-xs"
value={formData.city}
onChange={(e) => onChange({ city: e.target.value })}
/>
</div>
<div>
<label className="text-[9px] font-bold text-neutral-500 uppercase tracking-wider mb-1 block">ZIP Code</label>
<input
type="text"
inputMode="numeric"
placeholder="94103"
maxLength={6}
className="w-full px-3 py-2 rounded-lg bg-neutral-50 dark:bg-neutral-800/50 border border-neutral-200 dark:border-neutral-700 focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white text-xs font-mono"
value={formData.zip}
onChange={(e) => onChange({ zip: formatZip(e.target.value) })}
/>
</div>
</div>
</div>
<div className="pt-2 flex justify-end">
<button
onClick={onNext}
className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-neutral-950 dark:bg-white px-5 text-xs font-medium text-white dark:text-neutral-950"
>
Continue <ChevronRight className="w-3 h-3" />
</button>
</div>
</motion.div>
)
}
Source Code
'use client'
import { motion } from 'framer-motion'
import { Check } from 'lucide-react'
type SuccessStepProps = {
onReset: () => void
}
export function SuccessStep({ onReset }: SuccessStepProps) {
return (
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex flex-col items-center justify-center py-8 text-center"
>
<div className="w-20 h-20 rounded-full bg-green-500/10 flex items-center justify-center mb-4 relative">
<div className="w-16 h-16 rounded-full bg-green-500 flex items-center justify-center shadow-lg shadow-green-500/30">
<Check className="w-8 h-8 text-white stroke-[3px]" />
</div>
</div>
<h2 className="text-xl font-bold text-neutral-900 dark:text-white mb-2">Order Confirmed!</h2>
<p className="text-xs text-neutral-500 max-w-[200px] leading-relaxed mb-6">
Thank you for your purchase. A confirmation email has been sent to your inbox.
</p>
<button onClick={onReset} className="text-xs text-neutral-500 hover:text-neutral-900 dark:hover:text-white font-medium">
Start New Order
</button>
</motion.div>
)
}
types.ts
export type Step = 'shipping' | 'payment' | 'review' | 'success'
export interface FormData {
name: string
address: string
city: string
zip: string
cardNumber: string
expiry: string
cvv: string
}
export const INITIAL_FORM_DATA: FormData = {
name: '',
address: '',
city: '',
zip: '',
cardNumber: '',
expiry: '',
cvv: '',
}
Dependencies
framer-motion: latestlucide-react: latest
Props
Component property reference.
| Name | Type | Default | Description |
|---|---|---|---|
| step | 'shipping' | 'payment' | 'review' | 'success' | 'shipping' | Current step in the flow. |
| onNext | () => void | undefined | Callback to advance to the next step. |
| onSuccess | () => void | undefined | Callback fired when checkout completes. |
| className | string | undefined | Additional CSS classes. |
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.