Checkout

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-01
Open in v0

Code

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>
  )
}