Menu

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:

terminal
npx -y vui-registry-cli-v1@latest add dynamic-checkout

card-input-utils.ts

card-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

checkout-logo.tsx
'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

dynamic-checkout.tsx
'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

payment-card-3d.tsx
'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

payment-step.tsx
'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

review-step.tsx
'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

shipping-step.tsx
'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

success-step.tsx
'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

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: latest
  • lucide-react: latest

Props

Component property reference.

NameTypeDefaultDescription
step'shipping' | 'payment' | 'review' | 'success''shipping'Current step in the flow.
onNext() => voidundefinedCallback to advance to the next step.
onSuccess() => voidundefinedCallback fired when checkout completes.
classNamestringundefinedAdditional 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.