Split-panel checkout form with a 45/55 layout. Left panel contains cardholder name, email, formatted card number (XXXX XXXX XXXX XXXX), expiry/CVC grid, and country selector with a button state machine (idle → processing → success). Right panel shows plan summary with price, feature list, and secure checkout badge. Built with shadcn/ui Input, Label, and Button. No Stripe dependency — drop-in ready for any payment processor.
Files
"use client"
import * as React from "react"
import { ArrowLeft, Check, CreditCard, Lock, Zap } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
const plan = {
name: "Pro Plan",
price: 29,
description: "Everything you need to grow and scale your business.",
features: [
"Unlimited projects",
"100 GB storage",
"Advanced analytics",
"Priority support",
"API access",
"Custom integrations",
"Team collaboration",
],
}
const COUNTRIES = [
{ code: "US", name: "United States" },
{ code: "GB", name: "United Kingdom" },
{ code: "CA", name: "Canada" },
{ code: "AU", name: "Australia" },
{ code: "DE", name: "Germany" },
{ code: "FR", name: "France" },
{ code: "ES", name: "Spain" },
{ code: "IT", name: "Italy" },
{ code: "NL", name: "Netherlands" },
{ code: "MA", name: "Morocco" },
]
type PaymentStatus = "idle" | "processing" | "success" | "error"
function formatCardNumber(val: string) {
return val
.replace(/\D/g, "")
.slice(0, 16)
.replace(/(\d{4})(?=\d)/g, "$1 ")
}
function formatExpiry(val: string) {
const digits = val.replace(/\D/g, "").slice(0, 4)
return digits.length > 2 ? digits.slice(0, 2) + "/" + digits.slice(2) : digits
}
export default function CheckoutForm() {
const [status, setStatus] = React.useState<PaymentStatus>("idle")
const [form, setForm] = React.useState({
name: "",
email: "",
cardNumber: "",
expiry: "",
cvc: "",
country: "",
})
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) {
const { name, value } = e.target
if (name === "cardNumber") {
setForm((f) => ({ ...f, cardNumber: formatCardNumber(value) }))
} else if (name === "expiry") {
setForm((f) => ({ ...f, expiry: formatExpiry(value) }))
} else if (name === "cvc") {
setForm((f) => ({ ...f, cvc: value.replace(/\D/g, "").slice(0, 4) }))
} else {
setForm((f) => ({ ...f, [name]: value }))
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus("processing")
setTimeout(() => {
setStatus("success")
setTimeout(() => setStatus("idle"), 3000)
}, 1800)
}
const buttonLabel: Record<PaymentStatus, string> = {
idle: `Pay $${plan.price}/mo`,
processing: "Processing...",
success: "Payment succeeded 🎉",
error: "Try again",
}
return (
<div className="flex min-h-screen flex-col lg:flex-row">
<div className="bg-background w-full overflow-y-auto p-6 sm:p-10 lg:w-[45%]">
<div className="mx-auto max-w-md">
<a
href="#"
className="text-muted-foreground hover:text-foreground mb-8 inline-flex items-center gap-2 text-sm transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to plans
</a>
<div className="mb-8">
<h1 className="mb-1 text-2xl font-semibold tracking-tight">
Subscribe to {plan.name}
</h1>
<p className="text-muted-foreground text-sm">{plan.description}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="name">Cardholder name</Label>
<Input
id="name"
name="name"
placeholder="Jane Smith"
value={form.name}
onChange={handleChange}
required
autoComplete="cc-name"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="jane@example.com"
value={form.email}
onChange={handleChange}
required
autoComplete="email"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cardNumber">Card number</Label>
<div className="relative">
<Input
id="cardNumber"
name="cardNumber"
placeholder="1234 5678 9012 3456"
value={form.cardNumber}
onChange={handleChange}
required
autoComplete="cc-number"
inputMode="numeric"
className="pr-10"
/>
<CreditCard className="text-muted-foreground pointer-events-none absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="expiry">Expiry</Label>
<Input
id="expiry"
name="expiry"
placeholder="MM/YY"
value={form.expiry}
onChange={handleChange}
required
autoComplete="cc-exp"
inputMode="numeric"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cvc">CVC</Label>
<Input
id="cvc"
name="cvc"
placeholder="123"
value={form.cvc}
onChange={handleChange}
required
autoComplete="cc-csc"
inputMode="numeric"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="country">Country</Label>
<select
id="country"
name="country"
value={form.country}
onChange={handleChange}
required
className={cn(
"border-input bg-background ring-offset-background focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
!form.country && "text-muted-foreground"
)}
>
<option value="" disabled>
Select country
</option>
{COUNTRIES.map((c) => (
<option key={c.code} value={c.code}>
{c.name}
</option>
))}
</select>
</div>
<Button
type="submit"
size="lg"
className="w-full"
disabled={status === "processing" || status === "success"}
>
{buttonLabel[status]}
</Button>
</form>
<p className="text-muted-foreground mt-4 text-xs">
By subscribing, you agree to our Terms of Service and authorize us
to charge your card for future payments in accordance with our
terms.
</p>
</div>
</div>
<div className="bg-muted hidden lg:block lg:w-[55%]">
<div className="sticky top-0 h-screen overflow-y-auto p-12">
<div className="mx-auto max-w-xl">
<div className="mb-8 flex items-center gap-3">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-full">
<Zap className="text-primary h-5 w-5" />
</div>
<h3 className="text-xl font-medium">{plan.name}</h3>
</div>
<div className="mb-12">
<div className="mb-2 flex items-baseline gap-2">
<span className="text-5xl font-semibold">${plan.price}</span>
<span className="text-muted-foreground text-lg">per month</span>
</div>
<p className="text-muted-foreground text-sm">
Billed monthly. Cancel anytime.
</p>
</div>
<ul className="mb-10 space-y-3">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-3">
<div className="bg-primary/10 flex h-5 w-5 shrink-0 items-center justify-center rounded-full">
<Check className="text-primary h-3 w-3" />
</div>
<span className="text-sm">{feature}</span>
</li>
))}
</ul>
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Lock className="h-4 w-4 shrink-0" />
<span>Secure checkout. Your payment info is encrypted.</span>
</div>
</div>
</div>
</div>
</div>
)
}
Split-panel checkout form with card input and plan summary
checkout-form-01
Back to plans
Subscribe to Pro Plan
Everything you need to grow and scale your business.
By subscribing, you agree to our Terms of Service and authorize us to charge your card for future payments in accordance with our terms.
Pro Plan
$29per month
Billed monthly. Cancel anytime.
- Unlimited projects
- 100 GB storage
- Advanced analytics
- Priority support
- API access
- Custom integrations
- Team collaboration
Secure checkout. Your payment info is encrypted.
"use client"
import * as React from "react"
import { ArrowLeft, Check, CreditCard, Lock, Zap } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
const plan = {
name: "Pro Plan",
price: 29,
description: "Everything you need to grow and scale your business.",
features: [
"Unlimited projects",
"100 GB storage",
"Advanced analytics",
"Priority support",
"API access",
"Custom integrations",
"Team collaboration",
],
}
const COUNTRIES = [
{ code: "US", name: "United States" },
{ code: "GB", name: "United Kingdom" },
{ code: "CA", name: "Canada" },
{ code: "AU", name: "Australia" },
{ code: "DE", name: "Germany" },
{ code: "FR", name: "France" },
{ code: "ES", name: "Spain" },
{ code: "IT", name: "Italy" },
{ code: "NL", name: "Netherlands" },
{ code: "MA", name: "Morocco" },
]
type PaymentStatus = "idle" | "processing" | "success" | "error"
function formatCardNumber(val: string) {
return val
.replace(/\D/g, "")
.slice(0, 16)
.replace(/(\d{4})(?=\d)/g, "$1 ")
}
function formatExpiry(val: string) {
const digits = val.replace(/\D/g, "").slice(0, 4)
return digits.length > 2 ? digits.slice(0, 2) + "/" + digits.slice(2) : digits
}
export default function CheckoutForm() {
const [status, setStatus] = React.useState<PaymentStatus>("idle")
const [form, setForm] = React.useState({
name: "",
email: "",
cardNumber: "",
expiry: "",
cvc: "",
country: "",
})
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) {
const { name, value } = e.target
if (name === "cardNumber") {
setForm((f) => ({ ...f, cardNumber: formatCardNumber(value) }))
} else if (name === "expiry") {
setForm((f) => ({ ...f, expiry: formatExpiry(value) }))
} else if (name === "cvc") {
setForm((f) => ({ ...f, cvc: value.replace(/\D/g, "").slice(0, 4) }))
} else {
setForm((f) => ({ ...f, [name]: value }))
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus("processing")
setTimeout(() => {
setStatus("success")
setTimeout(() => setStatus("idle"), 3000)
}, 1800)
}
const buttonLabel: Record<PaymentStatus, string> = {
idle: `Pay $${plan.price}/mo`,
processing: "Processing...",
success: "Payment succeeded 🎉",
error: "Try again",
}
return (
<div className="flex min-h-screen flex-col lg:flex-row">
<div className="bg-background w-full overflow-y-auto p-6 sm:p-10 lg:w-[45%]">
<div className="mx-auto max-w-md">
<a
href="#"
className="text-muted-foreground hover:text-foreground mb-8 inline-flex items-center gap-2 text-sm transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to plans
</a>
<div className="mb-8">
<h1 className="mb-1 text-2xl font-semibold tracking-tight">
Subscribe to {plan.name}
</h1>
<p className="text-muted-foreground text-sm">{plan.description}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="name">Cardholder name</Label>
<Input
id="name"
name="name"
placeholder="Jane Smith"
value={form.name}
onChange={handleChange}
required
autoComplete="cc-name"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="jane@example.com"
value={form.email}
onChange={handleChange}
required
autoComplete="email"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cardNumber">Card number</Label>
<div className="relative">
<Input
id="cardNumber"
name="cardNumber"
placeholder="1234 5678 9012 3456"
value={form.cardNumber}
onChange={handleChange}
required
autoComplete="cc-number"
inputMode="numeric"
className="pr-10"
/>
<CreditCard className="text-muted-foreground pointer-events-none absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="expiry">Expiry</Label>
<Input
id="expiry"
name="expiry"
placeholder="MM/YY"
value={form.expiry}
onChange={handleChange}
required
autoComplete="cc-exp"
inputMode="numeric"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cvc">CVC</Label>
<Input
id="cvc"
name="cvc"
placeholder="123"
value={form.cvc}
onChange={handleChange}
required
autoComplete="cc-csc"
inputMode="numeric"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="country">Country</Label>
<select
id="country"
name="country"
value={form.country}
onChange={handleChange}
required
className={cn(
"border-input bg-background ring-offset-background focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
!form.country && "text-muted-foreground"
)}
>
<option value="" disabled>
Select country
</option>
{COUNTRIES.map((c) => (
<option key={c.code} value={c.code}>
{c.name}
</option>
))}
</select>
</div>
<Button
type="submit"
size="lg"
className="w-full"
disabled={status === "processing" || status === "success"}
>
{buttonLabel[status]}
</Button>
</form>
<p className="text-muted-foreground mt-4 text-xs">
By subscribing, you agree to our Terms of Service and authorize us
to charge your card for future payments in accordance with our
terms.
</p>
</div>
</div>
<div className="bg-muted hidden lg:block lg:w-[55%]">
<div className="sticky top-0 h-screen overflow-y-auto p-12">
<div className="mx-auto max-w-xl">
<div className="mb-8 flex items-center gap-3">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-full">
<Zap className="text-primary h-5 w-5" />
</div>
<h3 className="text-xl font-medium">{plan.name}</h3>
</div>
<div className="mb-12">
<div className="mb-2 flex items-baseline gap-2">
<span className="text-5xl font-semibold">${plan.price}</span>
<span className="text-muted-foreground text-lg">per month</span>
</div>
<p className="text-muted-foreground text-sm">
Billed monthly. Cancel anytime.
</p>
</div>
<ul className="mb-10 space-y-3">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-3">
<div className="bg-primary/10 flex h-5 w-5 shrink-0 items-center justify-center rounded-full">
<Check className="text-primary h-3 w-3" />
</div>
<span className="text-sm">{feature}</span>
</li>
))}
</ul>
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Lock className="h-4 w-4 shrink-0" />
<span>Secure checkout. Your payment info is encrypted.</span>
</div>
</div>
</div>
</div>
</div>
)
}