Checkout Form
Original · freecompact checkout summary + fields
byWeb InnoventixReact + Tailwind
formcheckoutforms
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/form-checkout.jsonform-checkout.tsx
"use client";
import { useMemo, useState } from "react";
import type { FormEvent, ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Brand = "visa" | "mastercard" | "amex" | "unknown";
type CartItem = {
id: string;
name: string;
variant: string;
unit: number; // cents
qty: number;
initials: string;
tint: string;
};
type Promo = { type: "pct" | "ship"; value: number; label: string };
const PROMOS: Record<string, Promo> = {
BREW15: { type: "pct", value: 0.15, label: "BREW15 · 15% off" },
FREESHIP: { type: "ship", value: 0, label: "FREESHIP · free shipping" },
};
const INITIAL_ITEMS: CartItem[] = [
{
id: "grinder",
name: "Nomad Hand Grinder",
variant: "Matte black · 48mm conical burr",
unit: 18900,
qty: 1,
initials: "NG",
tint: "bg-indigo-600",
},
{
id: "sampler",
name: "Single-Origin Sampler",
variant: "3 × 250g whole bean",
unit: 4200,
qty: 2,
initials: "SS",
tint: "bg-emerald-600",
},
];
const TAX_RATE = 0.0825;
const SHIP_FLAT = 800;
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const fmt = (c: number): string => `$${(c / 100).toFixed(2)}`;
function luhn(num: string): boolean {
let sum = 0;
let alt = false;
for (let i = num.length - 1; i >= 0; i -= 1) {
let n = Number(num[i]);
if (Number.isNaN(n)) return false;
if (alt) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alt = !alt;
}
return sum % 10 === 0;
}
function detectBrand(digits: string): Brand {
if (/^4/.test(digits)) return "visa";
if (/^(5[1-5]|2[2-7])/.test(digits)) return "mastercard";
if (/^3[47]/.test(digits)) return "amex";
return "unknown";
}
function expiryValid(v: string): boolean {
const m = /^(\d{2})\/(\d{2})$/.exec(v);
if (!m) return false;
const mm = Number(m[1]);
const yy = Number(m[2]);
if (mm < 1 || mm > 12) return false;
const now = new Date();
const curYY = now.getFullYear() % 100;
const curMM = now.getMonth() + 1;
if (yy < curYY) return false;
if (yy === curYY && mm < curMM) return false;
return true;
}
const BRAND_LABEL: Record<Brand, string> = {
visa: "Visa",
mastercard: "Mastercard",
amex: "Amex",
unknown: "",
};
const inputCls =
"w-full rounded-xl border border-slate-300 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm outline-none transition-colors placeholder:text-slate-400 focus:border-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500/40 aria-[invalid=true]:border-rose-400 aria-[invalid=true]:focus-visible:ring-rose-400/40 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500 dark:aria-[invalid=true]:border-rose-500";
const keyframes = `
@keyframes fckt-pop { 0% { transform: scale(.85); opacity: 0 } 60% { transform: scale(1.04) } 100% { transform: scale(1); opacity: 1 } }
@keyframes fckt-ring { 0% { transform: scale(.55); opacity: .55 } 100% { transform: scale(1.5); opacity: 0 } }
@keyframes fckt-shimmer { 0% { background-position: 200% 0 } 100% { background-position: -200% 0 } }
@keyframes fckt-check { to { stroke-dashoffset: 0 } }
.fckt-pop { animation: fckt-pop .4s cubic-bezier(.2,.8,.2,1) both; }
.fckt-ring { animation: fckt-ring 1.1s ease-out forwards; }
.fckt-check { stroke-dasharray: 30; stroke-dashoffset: 30; animation: fckt-check .5s ease-out .12s forwards; }
.fckt-shimmer { background-size: 200% 100%; animation: fckt-shimmer 2.6s linear infinite; }
.fckt-cb ~ span > svg { opacity: 0; transition: opacity .15s ease; }
.fckt-cb:checked ~ span > svg { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.fckt-pop, .fckt-ring, .fckt-shimmer, .fckt-check { animation: none !important; }
.fckt-check { stroke-dashoffset: 0 !important; }
}
`;
type TextFieldProps = {
id: string;
label: string;
value: string;
onChange: (v: string) => void;
onBlur?: () => void;
type?: string;
placeholder?: string;
autoComplete?: string;
inputMode?: "text" | "numeric" | "email" | "tel";
maxLength?: number;
error?: string;
hint?: string;
trailing?: ReactNode;
};
function TextField({
id,
label,
value,
onChange,
onBlur,
type = "text",
placeholder,
autoComplete,
inputMode,
maxLength,
error,
hint,
trailing,
}: TextFieldProps) {
const describedBy = error ? `${id}-error` : hint ? `${id}-hint` : undefined;
return (
<div>
<label
htmlFor={id}
className="mb-1.5 block text-xs font-medium text-slate-600 dark:text-slate-400"
>
{label}
</label>
<div className="relative">
<input
id={id}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
placeholder={placeholder}
autoComplete={autoComplete}
inputMode={inputMode}
maxLength={maxLength}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className={`${inputCls}${trailing ? " pr-20" : ""}`}
/>
{trailing ? (
<div className="pointer-events-none absolute inset-y-0 right-2.5 flex items-center">
{trailing}
</div>
) : null}
</div>
{error ? (
<p
id={`${id}-error`}
role="alert"
className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
>
{error}
</p>
) : hint ? (
<p id={`${id}-hint`} className="mt-1.5 text-xs text-slate-400 dark:text-slate-500">
{hint}
</p>
) : null}
</div>
);
}
type CheckProps = {
id: string;
checked: boolean;
onChange: (v: boolean) => void;
children: ReactNode;
};
function CheckRow({ id, checked, onChange, children }: CheckProps) {
return (
<label htmlFor={id} className="flex cursor-pointer items-start gap-3">
<input
id={id}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="fckt-cb peer sr-only"
/>
<span
aria-hidden="true"
className="mt-px flex h-5 w-5 flex-none items-center justify-center rounded-md border border-slate-300 bg-white transition-colors peer-checked:border-indigo-600 peer-checked:bg-indigo-600 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500/50 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:border-slate-600 dark:bg-slate-900 dark:peer-focus-visible:ring-offset-slate-900"
>
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5 text-white" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 10.5l4 4 8-9" />
</svg>
</span>
<span className="text-sm text-slate-600 dark:text-slate-300">{children}</span>
</label>
);
}
function LockIcon() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="4.5" y="10.5" width="15" height="10" rx="2" />
<path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
</svg>
);
}
function CardGlyph() {
return (
<svg viewBox="0 0 24 24" className="h-5 w-7 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
<rect x="2.5" y="5.5" width="19" height="13" rx="2.5" />
<path d="M2.5 9.5h19" strokeLinecap="round" />
</svg>
);
}
const legendCls =
"mb-4 flex items-center gap-2.5 text-sm font-semibold text-slate-900 dark:text-slate-100";
const stepCls =
"flex h-6 w-6 flex-none items-center justify-center rounded-full bg-indigo-600 text-xs font-bold text-white";
const cardCls =
"rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900/60 sm:p-6";
export default function FormCheckout() {
const reduce = useReducedMotion();
const [items, setItems] = useState<CartItem[]>(INITIAL_ITEMS);
const [email, setEmail] = useState("");
const [optIn, setOptIn] = useState(true);
const [fullName, setFullName] = useState("");
const [address, setAddress] = useState("");
const [city, setCity] = useState("");
const [zip, setZip] = useState("");
const [country, setCountry] = useState("United States");
const [nameOnCard, setNameOnCard] = useState("");
const [card, setCard] = useState("");
const [expiry, setExpiry] = useState("");
const [cvc, setCvc] = useState("");
const [billingSame, setBillingSame] = useState(true);
const [promoInput, setPromoInput] = useState("");
const [appliedPromo, setAppliedPromo] = useState<string | null>(null);
const [promoError, setPromoError] = useState("");
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [submitted, setSubmitted] = useState(false);
const [placed, setPlaced] = useState(false);
const [orderNo] = useState(() => `NB-${Math.floor(100000 + Math.random() * 900000)}`);
const digits = card.replace(/\s/g, "");
const brand = detectBrand(digits);
const totals = useMemo(() => {
const subtotal = items.reduce((s, i) => s + i.unit * i.qty, 0);
const promo = appliedPromo ? PROMOS[appliedPromo] : null;
const discount = promo && promo.type === "pct" ? Math.round(subtotal * promo.value) : 0;
const shipping = promo?.type === "ship" ? 0 : subtotal === 0 ? 0 : SHIP_FLAT;
const taxable = Math.max(0, subtotal - discount);
const tax = Math.round(taxable * TAX_RATE);
const total = taxable + shipping + tax;
return { subtotal, discount, shipping, tax, total };
}, [items, appliedPromo]);
const errors = useMemo(() => {
const e: Record<string, string> = {};
if (!emailRe.test(email)) e.email = "Enter a valid email address.";
if (fullName.trim().length < 2) e.fullName = "Enter your full name.";
if (address.trim().length < 4) e.address = "Enter your street address.";
if (city.trim().length < 2) e.city = "Enter your city.";
if (!/^\d{5}(-\d{4})?$/.test(zip.trim())) e.zip = "Enter a 5-digit ZIP.";
if (nameOnCard.trim().length < 2) e.nameOnCard = "Enter the name on the card.";
if (!(digits.length >= 13 && digits.length <= 19 && luhn(digits)))
e.card = "Enter a valid card number.";
if (!expiryValid(expiry)) e.expiry = "MM/YY, not expired.";
if (!/^\d{3,4}$/.test(cvc)) e.cvc = "3–4 digits.";
return e;
}, [email, fullName, address, city, zip, nameOnCard, digits, expiry, cvc]);
const show = (k: string): string | undefined =>
(touched[k] || submitted) && errors[k] ? errors[k] : undefined;
const mark = (k: string) => () => setTouched((t) => ({ ...t, [k]: true }));
const setQty = (id: string, delta: number) =>
setItems((prev) =>
prev.map((i) =>
i.id === id ? { ...i, qty: Math.min(9, Math.max(1, i.qty + delta)) } : i,
),
);
const onCardChange = (v: string) => {
const only = v.replace(/\D/g, "").slice(0, 19);
setCard(only.replace(/(.{4})/g, "$1 ").trim());
};
const onExpiryChange = (v: string) => {
const only = v.replace(/\D/g, "").slice(0, 4);
setExpiry(only.length >= 3 ? `${only.slice(0, 2)}/${only.slice(2)}` : only);
};
const applyPromo = () => {
const code = promoInput.trim().toUpperCase();
if (!code) return;
if (PROMOS[code]) {
setAppliedPromo(code);
setPromoError("");
setPromoInput("");
} else {
setAppliedPromo(null);
setPromoError(`"${code}" isn’t a valid code.`);
}
};
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setSubmitted(true);
if (Object.keys(errors).length === 0) setPlaced(true);
};
const firstName = fullName.trim().split(" ")[0] || "there";
return (
<section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:px-6 sm:py-20 lg:px-8">
<style>{keyframes}</style>
<div className="mx-auto max-w-5xl">
<header className="mb-8 sm:mb-10">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Northbeam Coffee · Secure checkout
</p>
<h2 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">
Review & complete your order
</h2>
<p className="mt-2 max-w-xl text-sm text-slate-500 dark:text-slate-400">
Your card is encrypted end-to-end. Orders can be edited or cancelled free for 60
minutes after they’re placed.
</p>
</header>
<AnimatePresence mode="wait">
{placed ? (
<motion.div
key="confirmed"
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className={`${cardCls} mx-auto max-w-lg text-center`}
>
<div className="relative mx-auto mb-5 flex h-16 w-16 items-center justify-center">
<span className="fckt-ring absolute inset-0 rounded-full bg-emerald-400/50" />
<span className="fckt-pop relative flex h-16 w-16 items-center justify-center rounded-full bg-emerald-500 text-white">
<svg viewBox="0 0 32 32" className="h-8 w-8" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path className="fckt-check" d="M9 16.5l4.5 4.5L23 11" />
</svg>
</span>
</div>
<h3 className="text-xl font-bold">Order confirmed</h3>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
Thanks, {firstName} — we charged{" "}
<span className="font-semibold text-slate-700 dark:text-slate-200">
{fmt(totals.total)}
</span>{" "}
and a receipt is on its way to{" "}
<span className="font-semibold text-slate-700 dark:text-slate-200">
{email || "your inbox"}
</span>
.
</p>
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-4 py-1.5 text-sm font-medium dark:border-slate-700 dark:bg-slate-800/60">
<span className="text-slate-500 dark:text-slate-400">Order</span>
<span className="font-mono font-semibold tracking-wide">{orderNo}</span>
</div>
<div className="mt-6">
<button
type="button"
onClick={() => {
setPlaced(false);
setSubmitted(false);
}}
className="rounded-xl px-4 py-2 text-sm font-semibold text-indigo-600 underline-offset-4 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-indigo-500/50 dark:text-indigo-400"
>
Place another order
</button>
</div>
</motion.div>
) : (
<motion.form
key="form"
onSubmit={handleSubmit}
noValidate
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="grid grid-cols-1 gap-6 lg:grid-cols-5 lg:items-start"
>
{/* Fields */}
<div className="space-y-6 lg:col-span-3">
<fieldset className={cardCls}>
<legend className={legendCls}>
<span className={stepCls}>1</span> Contact
</legend>
<div className="space-y-4">
<TextField
id="co-email"
label="Email address"
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@example.com"
value={email}
onChange={setEmail}
onBlur={mark("email")}
error={show("email")}
/>
<CheckRow id="co-optin" checked={optIn} onChange={setOptIn}>
Email me order updates and the occasional brewing guide.
</CheckRow>
</div>
</fieldset>
<fieldset className={cardCls}>
<legend className={legendCls}>
<span className={stepCls}>2</span> Shipping address
</legend>
<div className="space-y-4">
<TextField
id="co-name"
label="Full name"
autoComplete="name"
placeholder="Ada Lovelace"
value={fullName}
onChange={setFullName}
onBlur={mark("fullName")}
error={show("fullName")}
/>
<TextField
id="co-address"
label="Street address"
autoComplete="address-line1"
placeholder="118 Ferry Building"
value={address}
onChange={setAddress}
onBlur={mark("address")}
error={show("address")}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<TextField
id="co-city"
label="City"
autoComplete="address-level2"
placeholder="San Francisco"
value={city}
onChange={setCity}
onBlur={mark("city")}
error={show("city")}
/>
<TextField
id="co-zip"
label="ZIP code"
inputMode="numeric"
autoComplete="postal-code"
maxLength={10}
placeholder="94111"
value={zip}
onChange={setZip}
onBlur={mark("zip")}
error={show("zip")}
/>
</div>
<div>
<label
htmlFor="co-country"
className="mb-1.5 block text-xs font-medium text-slate-600 dark:text-slate-400"
>
Country / region
</label>
<select
id="co-country"
value={country}
onChange={(e) => setCountry(e.target.value)}
autoComplete="country-name"
className={inputCls}
>
<option>United States</option>
<option>Canada</option>
<option>United Kingdom</option>
<option>Australia</option>
</select>
</div>
</div>
</fieldset>
<fieldset className={cardCls}>
<legend className={legendCls}>
<span className={stepCls}>3</span> Payment
</legend>
<div className="space-y-4">
<TextField
id="co-cardname"
label="Name on card"
autoComplete="cc-name"
placeholder="Ada Lovelace"
value={nameOnCard}
onChange={setNameOnCard}
onBlur={mark("nameOnCard")}
error={show("nameOnCard")}
/>
<TextField
id="co-card"
label="Card number"
inputMode="numeric"
autoComplete="cc-number"
placeholder="4242 4242 4242 4242"
value={card}
onChange={onCardChange}
onBlur={mark("card")}
error={show("card")}
trailing={
brand !== "unknown" && digits.length > 1 ? (
<span className="rounded-md bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{BRAND_LABEL[brand]}
</span>
) : (
<CardGlyph />
)
}
/>
<div className="grid grid-cols-2 gap-4">
<TextField
id="co-exp"
label="Expiry"
inputMode="numeric"
autoComplete="cc-exp"
placeholder="MM/YY"
maxLength={5}
value={expiry}
onChange={onExpiryChange}
onBlur={mark("expiry")}
error={show("expiry")}
/>
<TextField
id="co-cvc"
label="Security code"
inputMode="numeric"
autoComplete="cc-csc"
placeholder={brand === "amex" ? "4 digits" : "3 digits"}
maxLength={4}
value={cvc}
onChange={(v) => setCvc(v.replace(/\D/g, "").slice(0, 4))}
onBlur={mark("cvc")}
error={show("cvc")}
/>
</div>
<CheckRow id="co-billing" checked={billingSame} onChange={setBillingSame}>
Billing address is the same as shipping.
</CheckRow>
</div>
</fieldset>
</div>
{/* Summary */}
<aside className="lg:sticky lg:top-8 lg:col-span-2">
<div className={`${cardCls} overflow-hidden p-0`}>
<div className="fckt-shimmer h-1 w-full bg-gradient-to-r from-transparent via-indigo-500 to-transparent" />
<div className="p-5 sm:p-6">
<h3 className="text-sm font-semibold">Order summary</h3>
<ul className="mt-4 space-y-4">
{items.map((item) => (
<li key={item.id} className="flex gap-3">
<div
className={`flex h-12 w-12 flex-none items-center justify-center rounded-xl text-sm font-bold text-white ${item.tint}`}
aria-hidden="true"
>
{item.initials}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.name}</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
{item.variant}
</p>
<div className="mt-2 flex items-center justify-between">
<div
className="inline-flex items-center rounded-lg border border-slate-200 dark:border-slate-700"
role="group"
aria-label={`Quantity for ${item.name}`}
>
<button
type="button"
onClick={() => setQty(item.id, -1)}
disabled={item.qty <= 1}
aria-label={`Decrease quantity of ${item.name}`}
className="flex h-7 w-7 items-center justify-center rounded-l-lg text-slate-600 outline-none transition-colors hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500/50 disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800"
>
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M3 8h10" />
</svg>
</button>
<span className="w-7 text-center text-sm font-semibold tabular-nums" aria-live="polite">
{item.qty}
</span>
<button
type="button"
onClick={() => setQty(item.id, 1)}
disabled={item.qty >= 9}
aria-label={`Increase quantity of ${item.name}`}
className="flex h-7 w-7 items-center justify-center rounded-r-lg text-slate-600 outline-none transition-colors hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500/50 disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800"
>
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M8 3v10M3 8h10" />
</svg>
</button>
</div>
<span className="text-sm font-semibold tabular-nums">
{fmt(item.unit * item.qty)}
</span>
</div>
</div>
</li>
))}
</ul>
{/* Promo */}
<div className="mt-5 border-t border-slate-200 pt-5 dark:border-slate-800">
<label
htmlFor="co-promo"
className="mb-1.5 block text-xs font-medium text-slate-600 dark:text-slate-400"
>
Promo code
</label>
<div className="flex gap-2">
<input
id="co-promo"
value={promoInput}
onChange={(e) => {
setPromoInput(e.target.value);
if (promoError) setPromoError("");
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyPromo();
}
}}
placeholder="Enter code"
aria-invalid={promoError ? true : undefined}
aria-describedby={promoError ? "co-promo-error" : "co-promo-hint"}
className={inputCls}
/>
<button
type="button"
onClick={applyPromo}
className="flex-none rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700 outline-none transition-colors hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-indigo-500/50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
Apply
</button>
</div>
<div aria-live="polite">
{promoError ? (
<p
id="co-promo-error"
role="alert"
className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
>
{promoError}
</p>
) : (
<p
id="co-promo-hint"
className="mt-1.5 text-xs text-slate-400 dark:text-slate-500"
>
Try <span className="font-semibold">BREW15</span> or{" "}
<span className="font-semibold">FREESHIP</span>.
</p>
)}
<AnimatePresence>
{appliedPromo ? (
<motion.div
initial={reduce ? false : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={{ duration: 0.25 }}
className="overflow-hidden"
>
<div className="mt-2 flex items-center justify-between rounded-lg bg-emerald-50 px-3 py-2 text-xs font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
<span className="flex items-center gap-1.5">
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 8.5l3 3 7-7.5" />
</svg>
{PROMOS[appliedPromo].label}
</span>
<button
type="button"
onClick={() => setAppliedPromo(null)}
className="rounded text-emerald-700 underline-offset-2 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-emerald-500/50 dark:text-emerald-300"
>
Remove
</button>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
{/* Totals */}
<dl className="mt-5 space-y-2.5 border-t border-slate-200 pt-5 text-sm dark:border-slate-800">
<div className="flex justify-between">
<dt className="text-slate-500 dark:text-slate-400">Subtotal</dt>
<dd className="tabular-nums">{fmt(totals.subtotal)}</dd>
</div>
{totals.discount > 0 ? (
<div className="flex justify-between text-emerald-600 dark:text-emerald-400">
<dt>Discount</dt>
<dd className="tabular-nums">-{fmt(totals.discount)}</dd>
</div>
) : null}
<div className="flex justify-between">
<dt className="text-slate-500 dark:text-slate-400">Shipping</dt>
<dd className="tabular-nums">
{totals.shipping === 0 ? "Free" : fmt(totals.shipping)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-500 dark:text-slate-400">Tax (8.25%)</dt>
<dd className="tabular-nums">{fmt(totals.tax)}</dd>
</div>
</dl>
<div className="mt-4 flex items-baseline justify-between border-t border-slate-200 pt-4 dark:border-slate-800">
<span className="text-base font-semibold">Total</span>
<span className="text-xl font-bold tabular-nums">{fmt(totals.total)}</span>
</div>
<button
type="submit"
className="mt-5 flex w-full items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-sm outline-none transition-colors hover:bg-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:bg-indigo-700 dark:focus-visible:ring-offset-slate-900"
>
<LockIcon />
Pay {fmt(totals.total)}
</button>
{submitted && Object.keys(errors).length > 0 ? (
<p role="alert" className="mt-3 text-center text-xs font-medium text-rose-600 dark:text-rose-400">
Please fix the highlighted fields above.
</p>
) : null}
<div className="mt-4 flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 text-xs text-slate-400 dark:text-slate-500">
<span className="flex items-center gap-1.5">
<LockIcon /> 256-bit TLS
</span>
<span>PCI-DSS compliant</span>
<span>30-day returns</span>
</div>
</div>
</div>
</aside>
</motion.form>
)}
</AnimatePresence>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Login Form
Originalclean login form with social buttons

Signup Form
Originalsignup form with password strength

Contact Form
Originalcontact form with fields and message

Multistep Form
Originalmulti-step wizard form with progress

Payment Form
Originalcard payment form with formatting

Newsletter Form
Originalinline newsletter subscribe form

Search Filters Form
Originalfaceted search filter panel

Profile Form
Originalprofile settings form

OTP Verify Form
OriginalOTP verification form

Upload Dropzone Form
Originaldrag-and-drop file upload with preview

Feedback Form
Originalfeedback form with emoji rating

Survey Form
Originalshort survey with radios and scale

