← CheckoutOpen in v0
Checkout 01 — Checkout
Single-page checkout: contact, shipping address, selectable delivery methods and a payment mock, beside a sticky order summary with live totals and a place-order CTA — accessible labels throughout.
$ npx shadcn@latest add @ludusvibe/checkout-01Code
Installed to components/sections/checkout-01.tsx — it's yours to edit.
"use client"
import * as React from "react"
import { motion } from "motion/react"
import { CreditCard, Lock, ShieldCheck, Truck } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { fadeUp, stagger, viewport } from "@/lib/motion"
import { cn } from "@/lib/utils"
export type CheckoutItem = {
name: string
qty: number
price: number
/** Optional real thumbnail. Falls back to a CSS mock swatch. */
image?: string
}
export type CheckoutDeliveryOption = {
id: string
name: string
eta: string
/** Shipping fee in the section currency. `0` renders as "Free". */
fee: number
}
export type Checkout01Props = {
index?: string
eyebrow?: string
title?: React.ReactNode
description?: string
items?: CheckoutItem[]
currency?: string
deliveryOptions?: CheckoutDeliveryOption[]
taxRate?: number
submitLabel?: string
secureNote?: string
paymentMethods?: string[]
/** Heading element — use "h1" when this is the page's main heading. */
as?: "h1" | "h2"
className?: string
}
const defaultItems: CheckoutItem[] = [
{ name: "Aera Wool Overshirt", qty: 1, price: 168 },
{ name: "Everyday Merino Tee", qty: 2, price: 48 },
{ name: "Canvas Weekender Bag", qty: 1, price: 124 },
]
const defaultDeliveryOptions: CheckoutDeliveryOption[] = [
{ id: "standard", name: "Standard", eta: "5–7 business days", fee: 0 },
{ id: "express", name: "Express", eta: "1–2 business days", fee: 14 },
{ id: "pickup", name: "Store pickup", eta: "Ready today", fee: 0 },
]
function formatMoney(value: number, currency: string) {
return `${currency}${value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`
}
function ThumbMock({ item }: { item: CheckoutItem }) {
if (item.image) {
return (
<img
src={item.image}
alt={item.name}
className="size-14 shrink-0 rounded-lg border object-cover"
/>
)
}
return (
<div
aria-hidden
className="size-14 shrink-0 rounded-lg border bg-gradient-to-br from-primary/25 via-muted to-background"
/>
)
}
export function Checkout01({
index = "07",
eyebrow = "Secure checkout",
title = "Checkout",
description = "Review your order and complete payment. Every field is encrypted end to end.",
items = defaultItems,
currency = "$",
deliveryOptions = defaultDeliveryOptions,
taxRate = 0.08,
submitLabel = "Place order",
secureNote = "Encrypted and secured. We never store full card details.",
paymentMethods = ["Visa", "Mastercard", "Amex", "Apple Pay"],
as: Heading = "h2",
className,
}: Checkout01Props) {
const options = deliveryOptions.length > 0 ? deliveryOptions : defaultDeliveryOptions
const [selectedDelivery, setSelectedDelivery] = React.useState(options[0]!.id)
const activeDelivery =
options.find((o) => o.id === selectedDelivery) ?? options[0]!
const subtotal = React.useMemo(
() => items.reduce((sum, item) => sum + item.price * item.qty, 0),
[items]
)
const shipping = activeDelivery.fee
const tax = React.useMemo(
() => Math.round((subtotal + shipping) * taxRate * 100) / 100,
[subtotal, shipping, taxRate]
)
const total = subtotal + shipping + tax
const stepIndex = (n: number) => (
<span
aria-hidden
className="flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium text-muted-foreground tabular-nums"
>
{n}
</span>
)
return (
<section className={cn("relative bg-background px-6 py-20 md:py-28", className)}>
<div className="mx-auto max-w-6xl">
<motion.div
initial="hidden"
whileInView="visible"
viewport={viewport}
variants={stagger(0.08)}
className="max-w-2xl"
>
<motion.p
variants={fadeUp}
className="flex items-center gap-2 text-xs font-medium tracking-[0.2em] text-primary uppercase"
>
<Lock className="size-3.5" aria-hidden />
{eyebrow}
</motion.p>
<motion.div variants={fadeUp}>
<Heading className="mt-4 font-heading text-4xl font-semibold tracking-tight text-balance md:text-5xl">
{title}
</Heading>
</motion.div>
<motion.p
variants={fadeUp}
className="mt-4 max-w-[60ch] text-pretty text-muted-foreground"
>
{description}
</motion.p>
</motion.div>
<div className="mt-14 grid items-start gap-10 lg:grid-cols-[1.4fr_1fr] lg:gap-16">
{/* LEFT — the form */}
<motion.form
initial="hidden"
whileInView="visible"
viewport={viewport}
variants={stagger(0.08)}
id="checkout-form"
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-10"
>
{/* 1) Contact */}
<motion.fieldset variants={fadeUp} className="flex flex-col gap-4 border-0 p-0">
<legend className="mb-1 flex items-center gap-2 text-sm font-medium">
{stepIndex(1)}
Contact
</legend>
<div className="flex flex-col gap-2">
<Label htmlFor="checkout-email">Email address</Label>
<Input
id="checkout-email"
name="email"
type="email"
autoComplete="email"
required
placeholder="you@example.com"
/>
</div>
</motion.fieldset>
{/* 2) Shipping address */}
<motion.fieldset variants={fadeUp} className="flex flex-col gap-4 border-0 p-0">
<legend className="mb-1 flex items-center gap-2 text-sm font-medium">
{stepIndex(2)}
Shipping address
</legend>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="checkout-first-name">First name</Label>
<Input
id="checkout-first-name"
name="firstName"
autoComplete="given-name"
required
placeholder="Ada"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="checkout-last-name">Last name</Label>
<Input
id="checkout-last-name"
name="lastName"
autoComplete="family-name"
required
placeholder="Lovelace"
/>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="checkout-address">Address</Label>
<Input
id="checkout-address"
name="address"
autoComplete="address-line1"
required
placeholder="12 Marchmont Street"
/>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="checkout-apartment">
Apartment{" "}
<span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="checkout-apartment"
name="apartment"
autoComplete="address-line2"
placeholder="Flat 4B"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="checkout-city">City</Label>
<Input
id="checkout-city"
name="city"
autoComplete="address-level2"
required
placeholder="London"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="checkout-postal">Postal code</Label>
<Input
id="checkout-postal"
name="postalCode"
autoComplete="postal-code"
required
placeholder="WC1N 1AL"
/>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="checkout-country">Country</Label>
<Input
id="checkout-country"
name="country"
autoComplete="country-name"
required
placeholder="United Kingdom"
/>
</div>
</div>
</motion.fieldset>
{/* 3) Delivery method */}
<motion.fieldset variants={fadeUp} className="flex flex-col gap-4 border-0 p-0">
<legend
id="checkout-delivery-label"
className="mb-1 flex items-center gap-2 text-sm font-medium"
>
{stepIndex(3)}
Delivery method
</legend>
<div
role="radiogroup"
aria-labelledby="checkout-delivery-label"
className="flex flex-col gap-3"
>
{options.map((option) => {
const selected = option.id === selectedDelivery
return (
<button
key={option.id}
type="button"
role="radio"
aria-checked={selected}
onClick={() => setSelectedDelivery(option.id)}
className={cn(
"flex items-center gap-4 rounded-xl border bg-card px-4 py-3.5 text-left transition-colors",
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
selected
? "border-primary bg-primary/5 ring-2 ring-primary"
: "hover:border-primary/40"
)}
>
<span
aria-hidden
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg border",
selected
? "border-primary/30 bg-primary/10 text-primary"
: "bg-muted text-muted-foreground"
)}
>
<Truck className="size-4" />
</span>
<span className="flex flex-1 flex-col">
<span className="text-sm font-medium">{option.name}</span>
<span className="text-xs text-muted-foreground">
{option.eta}
</span>
</span>
<span
className={cn(
"text-sm font-medium tabular-nums",
selected ? "text-primary" : "text-muted-foreground"
)}
>
{option.fee === 0
? "Free"
: formatMoney(option.fee, currency)}
</span>
</button>
)
})}
</div>
</motion.fieldset>
{/* 4) Payment */}
<motion.fieldset variants={fadeUp} className="flex flex-col gap-4 border-0 p-0">
<legend className="mb-1 flex items-center gap-2 text-sm font-medium">
{stepIndex(4)}
Payment
</legend>
<div className="rounded-xl border bg-card p-5">
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<Lock className="size-3.5 text-primary" aria-hidden />
Payments are encrypted and processed securely.
</p>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2 sm:col-span-2">
<Label htmlFor="checkout-card">Card number</Label>
<Input
id="checkout-card"
name="cardNumber"
inputMode="numeric"
autoComplete="cc-number"
required
placeholder="1234 5678 9012 3456"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="checkout-expiry">Expiry</Label>
<Input
id="checkout-expiry"
name="expiry"
inputMode="numeric"
autoComplete="cc-exp"
required
placeholder="MM / YY"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="checkout-cvc">CVC</Label>
<Input
id="checkout-cvc"
name="cvc"
inputMode="numeric"
autoComplete="cc-csc"
required
placeholder="123"
/>
</div>
</div>
</div>
</motion.fieldset>
</motion.form>
{/* RIGHT — order summary */}
<motion.aside
initial="hidden"
whileInView="visible"
viewport={viewport}
variants={fadeUp}
custom={0.1}
className="lg:sticky lg:top-24"
>
<div className="rounded-2xl border bg-card p-6">
<h3 className="text-sm font-medium">Order summary</h3>
<ul className="mt-5 flex flex-col gap-4">
{items.map((item, i) => (
<li key={`${item.name}-${i}`} className="flex items-center gap-3">
<ThumbMock item={item} />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">
{item.name}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
Qty {item.qty} × {formatMoney(item.price, currency)}
</span>
</div>
<span className="text-sm font-medium tabular-nums">
{formatMoney(item.price * item.qty, currency)}
</span>
</li>
))}
</ul>
<Separator className="my-5" />
<dl className="flex flex-col gap-2.5 text-sm">
<div className="flex items-center justify-between">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="tabular-nums">{formatMoney(subtotal, currency)}</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-muted-foreground">
Shipping{" "}
<span className="text-muted-foreground/70">
· {activeDelivery.name}
</span>
</dt>
<dd className="tabular-nums">
{shipping === 0 ? "Free" : formatMoney(shipping, currency)}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-muted-foreground">
Tax{" "}
<span className="text-muted-foreground/70 tabular-nums">
· {Math.round(taxRate * 100)}%
</span>
</dt>
<dd className="tabular-nums">{formatMoney(tax, currency)}</dd>
</div>
</dl>
<Separator className="my-5" />
<div className="flex items-baseline justify-between">
<span className="text-sm font-medium">Total</span>
<span className="font-heading text-2xl font-semibold tracking-tight tabular-nums">
{formatMoney(total, currency)}
</span>
</div>
<button
type="submit"
form="checkout-form"
className={cn(
buttonVariants({ size: "lg" }),
"mt-6 w-full gap-2"
)}
>
<Lock className="size-4" aria-hidden />
{submitLabel} · {formatMoney(total, currency)}
</button>
<p className="mt-4 flex items-start gap-2 text-xs text-muted-foreground">
<ShieldCheck className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden />
{secureNote}
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<CreditCard className="size-4 text-muted-foreground" aria-hidden />
{paymentMethods.map((method) => (
<span
key={method}
className="rounded-md border bg-muted px-2 py-1 text-xs text-muted-foreground"
>
{method}
</span>
))}
</div>
</div>
</motion.aside>
</div>
</div>
</section>
)
}