Checkout Payment Form with Order Summary

A two-column checkout. The payment column has an email field, a joined card block for number, expiry and security code, the name on the card, country and ZIP code, and a pay button that repeats the total. The summary column on a tinted background lists three items with drawn thumbnails and plus and minus buttons, then subtotal, delivery, tax and total. Changing a quantity updates every figure, including the button. All card values are obvious samples and the form does not send anything. On phones the summary comes first and the payment form follows.

Code

import { useState } from "react";

const products = [
  { id: "mugs", name: "Stoneware mug, set of two", detail: "Oat glaze", price: 38, tile: "bg-amber-100 text-amber-700", fill: "M10 13h16v12a4 4 0 0 1-4 4h-8a4 4 0 0 1-4-4V13Z", line: "M26 16.5h2a3 3 0 0 1 0 6h-2" },
  { id: "dripper", name: "Pour-over dripper", detail: "Speckled white", price: 46, tile: "bg-stone-200 text-stone-600", fill: "M9 11h22l-7 12h-8L9 11Zm3 15h16v3H12v-3Z", line: "M20 23v3" },
  { id: "towel", name: "Linen tea towel", detail: "Moss green", price: 14, tile: "bg-emerald-100 text-emerald-700", fill: "M11 8h18v3H11V8Zm0 5.5h18v13H11v-13Zm0 15.5h18v3H11v-3Z", line: "" },
];

const SHIPPING = 6;
const TAX_RATE = 0.08;
const money = (n: number) => `$${n.toFixed(2)}`;

const field = "mt-2 block h-11 w-full rounded-md border border-stone-300 bg-white px-3 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-700/20";
const ring = "focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700 focus-visible:ring-offset-2";

export default function CheckoutPaymentFormWithOrderSummary() {
  const [qty, setQty] = useState<Record<string, number>>({ mugs: 1, dripper: 1, towel: 2 });
  const change = (id: string, by: number) => setQty({ ...qty, [id]: Math.max(1, Math.min(9, qty[id] + by)) });

  const subtotal = products.reduce((sum, p) => sum + p.price * qty[p.id], 0);
  const tax = subtotal * TAX_RATE;
  const total = subtotal + SHIPPING + tax;

  return (
    <section className="relative bg-white text-stone-900">
      <div aria-hidden="true" className="absolute inset-y-0 right-0 hidden w-1/2 bg-stone-100 lg:block" />
      <div className="relative mx-auto grid max-w-6xl lg:grid-cols-2">
        <div className="bg-stone-100 px-4 py-10 sm:px-10 lg:order-last lg:py-16">
          <div className="mx-auto max-w-md">
            <h2 className="text-lg font-semibold tracking-tight">Your order</h2>
            <ul className="mt-6 space-y-5">
              {products.map((p) => (
                <li key={p.id} className="flex gap-4">
                  <span aria-hidden="true" className={`flex h-16 w-16 flex-none items-center justify-center rounded-md ${p.tile}`}>
                    <svg viewBox="0 0 40 40" className="h-11 w-11">
                      <path d={p.fill} fill="currentColor" />
                      {p.line && <path d={p.line} fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
                    </svg>
                  </span>
                  <div className="min-w-0 flex-1">
                    <div className="flex justify-between gap-3">
                      <p className="text-sm font-medium">{p.name}</p>
                      <p className="flex-none text-sm font-medium tabular-nums">{money(p.price * qty[p.id])}</p>
                    </div>
                    <p className="mt-0.5 text-sm text-stone-600">{p.detail}</p>
                    <div className="mt-1 inline-flex items-center rounded-md border border-stone-300 bg-white">
                      <button type="button" aria-label={`Remove one ${p.name}`} disabled={qty[p.id] === 1} onClick={() => change(p.id, -1)} className={`flex h-10 w-10 items-center justify-center rounded-l-md text-stone-700 hover:bg-stone-50 disabled:opacity-40 ${ring}`}>
                        <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" aria-hidden="true" className="h-4 w-4"><path d="M5 10h10" /></svg>
                      </button>
                      <span aria-live="polite" className="w-8 text-center text-sm font-medium tabular-nums">{qty[p.id]}</span>
                      <button type="button" aria-label={`Add one ${p.name}`} onClick={() => change(p.id, 1)} className={`flex h-10 w-10 items-center justify-center rounded-r-md text-stone-700 hover:bg-stone-50 ${ring}`}>
                        <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" aria-hidden="true" className="h-4 w-4"><path d="M5 10h10M10 5v10" /></svg>
                      </button>
                    </div>
                  </div>
                </li>
              ))}
            </ul>
            <dl className="mt-8 space-y-3 border-t border-stone-300 pt-6 text-sm">
              <div className="flex justify-between"><dt className="text-stone-600">Subtotal</dt><dd className="font-medium tabular-nums">{money(subtotal)}</dd></div>
              <div className="flex justify-between"><dt className="text-stone-600">Standard delivery</dt><dd className="font-medium tabular-nums">{money(SHIPPING)}</dd></div>
              <div className="flex justify-between"><dt className="text-stone-600">Estimated tax</dt><dd className="font-medium tabular-nums">{money(tax)}</dd></div>
              <div className="flex items-baseline justify-between border-t border-stone-300 pt-4">
                <dt className="text-base font-semibold">Total</dt>
                <dd className="text-2xl font-semibold tracking-tight tabular-nums">{money(total)}</dd>
              </div>
            </dl>
          </div>
        </div>

        <form action="#" onSubmit={(e) => e.preventDefault()} className="px-4 py-10 sm:px-10 lg:py-16">
          <div className="mx-auto max-w-md">
            <p className="text-sm font-semibold text-emerald-800">Fernhill Pottery</p>
            <h2 className="mt-2 text-2xl font-semibold tracking-tight">Payment</h2>
            <p className="mt-3 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-900">Sample card details are filled in. Nothing is charged.</p>

            <div className="mt-7">
              <label htmlFor="checkout-email" className="text-sm font-medium">Email for the receipt</label>
              <input id="checkout-email" type="email" autoComplete="email" defaultValue="[email protected]" className={field} />
            </div>

            <fieldset className="mt-7">
              <legend className="text-sm font-medium">Card details</legend>
              <div className="mt-2 rounded-md border border-stone-300 focus-within:border-emerald-700 focus-within:ring-2 focus-within:ring-emerald-700/20">
                <div className="relative">
                  <label htmlFor="checkout-card" className="sr-only">Card number</label>
                  <input id="checkout-card" type="text" inputMode="numeric" autoComplete="cc-number" defaultValue="4242 4242 4242 4242" className="block h-11 w-full rounded-t-md bg-transparent pl-3 pr-12 text-sm tabular-nums focus:outline-none" />
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true" className="pointer-events-none absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 text-stone-600">
                    <rect x="3" y="5.5" width="18" height="13" rx="2" />
                    <path d="M3 10h18M7 15h3" />
                  </svg>
                </div>
                <div className="grid grid-cols-2 border-t border-stone-300">
                  <div>
                    <label htmlFor="checkout-expiry" className="sr-only">Expiry date</label>
                    <input id="checkout-expiry" type="text" inputMode="numeric" autoComplete="cc-exp" placeholder="MM / YY" defaultValue="12 / 34" className="block h-11 w-full rounded-bl-md bg-transparent px-3 text-sm tabular-nums focus:outline-none" />
                  </div>
                  <div className="border-l border-stone-300">
                    <label htmlFor="checkout-cvc" className="sr-only">Security code</label>
                    <input id="checkout-cvc" type="text" inputMode="numeric" autoComplete="cc-csc" placeholder="CVC" defaultValue="123" className="block h-11 w-full rounded-br-md bg-transparent px-3 text-sm tabular-nums focus:outline-none" />
                  </div>
                </div>
              </div>
            </fieldset>

            <div className="mt-5">
              <label htmlFor="checkout-name" className="text-sm font-medium">Name on card</label>
              <input id="checkout-name" type="text" autoComplete="cc-name" defaultValue="Sample Cardholder" className={field} />
            </div>

            <div className="mt-5 grid grid-cols-5 gap-4">
              <div className="col-span-3">
                <label htmlFor="checkout-country" className="text-sm font-medium">Country</label>
                <div className="relative">
                  <select id="checkout-country" autoComplete="country-name" defaultValue="United States" className={`${field} appearance-none pr-9`}>
                    <option>United States</option>
                    <option>Canada</option>
                    <option>United Kingdom</option>
                    <option>Australia</option>
                  </select>
                  <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="pointer-events-none absolute right-3 top-1/2 mt-1 h-4 w-4 -translate-y-1/2 text-stone-600">
                    <path d="m5 7.5 5 5 5-5" />
                  </svg>
                </div>
              </div>
              <div className="col-span-2">
                <label htmlFor="checkout-zip" className="text-sm font-medium">ZIP code</label>
                <input id="checkout-zip" type="text" autoComplete="postal-code" defaultValue="00000" className={field} />
              </div>
            </div>

            <button type="submit" className={`mt-8 flex h-12 w-full items-center justify-center gap-2 rounded-md bg-emerald-800 text-sm font-semibold text-white transition hover:bg-emerald-700 ${ring}`}>
              <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4">
                <rect x="4" y="9" width="12" height="8" rx="1.5" />
                <path d="M7 9V6.5a3 3 0 0 1 6 0V9" />
              </svg>
              Pay {money(total)}
            </button>
            <p className="mt-4 text-center text-xs text-stone-600">Free returns within 30 days. Your card is charged when the order ships.</p>
          </div>
        </form>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/checkout-payment-form-with-order-summary.json

When to use it

How to customize it

Accessibility

Questions

Can I take real payments with this form?

Not on its own. Card numbers must be collected by a payment provider. Keep this layout and replace the card block with the secure fields your provider gives you, then confirm the payment in the submit handler.

Why are the card number, expiry and security code joined in one box?

Grouping them shows they belong together and takes less room on a phone. Each is still a separate input with its own label and autofill hint.

How is the total calculated?

The subtotal is each price times its quantity. Tax is the subtotal times `TAX_RATE`, and the total adds `SHIPPING`. All of it is recomputed on every render, so it never goes out of date.

How do I add a discount code field?

Add an input and an Apply button above the totals, keep the discount amount in state, show it as an extra row in the list and subtract it in the `total` line.

Should the order summary be above or below the form on mobile?

Above is common because buyers want to confirm the amount before typing card details. If your order is long, move it below by adding `order-last` to the summary column.

Related: Multi-Step Signup Form with Step Indicator, Contact Form with Topic Select and Details Panel, Pricing Table with Monthly and Yearly Toggle, Button Set: Primary, Secondary, Ghost, Icon and Loading.

More forms or all free UI components. Start for free to have a full site built around it.

More in Forms

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap