Checkout Stepper
Original · freecheckout progress steps
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/step-checkout.json"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type StepIndex = 0 | 1 | 2 | 3;
type FormData = {
fullName: string;
email: string;
address: string;
city: string;
postal: string;
country: string;
cardName: string;
cardNumber: string;
expiry: string;
cvc: string;
};
type Errors = Partial<Record<keyof FormData, string>>;
type Status = "idle" | "placing" | "done";
const STEPS: { label: string; hint: string }[] = [
{ label: "Contact", hint: "How we reach you" },
{ label: "Shipping", hint: "Where it ships" },
{ label: "Payment", hint: "Secure card details" },
{ label: "Review", hint: "Confirm & place" },
];
const COUNTRIES = [
"United Kingdom",
"United States",
"Canada",
"Ireland",
"Germany",
"France",
"Netherlands",
"Australia",
];
const ORDER: { name: string; variant: string; qty: number; price: number }[] = [
{ name: "Aeronaut Merino Crew", variant: "Slate / M", qty: 1, price: 128 },
{ name: "Terra Canvas Weekender", variant: "Olive", qty: 1, price: 245 },
{ name: "Drift Wool Beanie", variant: "Charcoal", qty: 2, price: 34 },
];
const INITIAL: FormData = {
fullName: "",
email: "",
address: "",
city: "",
postal: "",
country: "",
cardName: "",
cardNumber: "",
expiry: "",
cvc: "",
};
const onlyDigits = (s: string) => s.replace(/\D+/g, "");
const money = (n: number) => "$" + n.toFixed(2);
function formatCard(s: string): string {
const d = onlyDigits(s).slice(0, 16);
return d.replace(/(.{4})/g, "$1 ").trim();
}
function formatExpiry(s: string): string {
const d = onlyDigits(s).slice(0, 4);
if (d.length <= 2) return d;
return d.slice(0, 2) + "/" + d.slice(2);
}
function luhnValid(num: string): boolean {
const d = onlyDigits(num);
if (d.length < 13) return false;
let sum = 0;
let alt = false;
for (let i = d.length - 1; i >= 0; i--) {
let n = d.charCodeAt(i) - 48;
if (alt) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alt = !alt;
}
return sum % 10 === 0;
}
function isValidExpiry(v: string): boolean {
const m = /^(\d{2})\/(\d{2})$/.exec(v.trim());
if (!m) return false;
const mm = parseInt(m[1], 10);
const yy = parseInt(m[2], 10);
if (mm < 1 || mm > 12) return false;
const nowY = 26;
const nowM = 7;
if (yy < nowY) return false;
if (yy === nowY && mm < nowM) return false;
return true;
}
function validateStep(step: number, data: FormData): Errors {
const e: Errors = {};
if (step === 0) {
if (!data.fullName.trim()) e.fullName = "Enter your full name.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) e.email = "Enter a valid email address.";
} else if (step === 1) {
if (!data.address.trim()) e.address = "Enter your street address.";
if (!data.city.trim()) e.city = "Enter your city or town.";
if (data.postal.trim().length < 3) e.postal = "Enter a valid postal code.";
if (!data.country) e.country = "Select a country.";
} else if (step === 2) {
if (!data.cardName.trim()) e.cardName = "Enter the name on the card.";
if (onlyDigits(data.cardNumber).length !== 16 || !luhnValid(data.cardNumber))
e.cardNumber = "Enter a valid 16-digit card number.";
if (!isValidExpiry(data.expiry)) e.expiry = "Use MM/YY, not expired.";
if (!/^\d{3,4}$/.test(data.cvc)) e.cvc = "3 or 4 digits.";
}
return e;
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M5 12.5l4.2 4.2L19 7"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function LockIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="4.5" y="10.5" width="15" height="9.5" rx="2" stroke="currentColor" strokeWidth="1.8" />
<path d="M8 10.5V8a4 4 0 0 1 8 0v2.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="12" cy="15" r="1.4" fill="currentColor" />
</svg>
);
}
function ArrowIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function CardIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="3" y="5.5" width="18" height="13" rx="2.2" stroke="currentColor" strokeWidth="1.7" />
<path d="M3 9.5h18" stroke="currentColor" strokeWidth="1.7" />
<path d="M6.5 14.5h4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
);
}
function TruckIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M2.5 6.5h11v8.5h-11z" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
<path d="M13.5 9.5H18l3 3v2.5h-7.5z" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
<circle cx="7" cy="17.5" r="1.8" stroke="currentColor" strokeWidth="1.7" />
<circle cx="17.5" cy="17.5" r="1.8" stroke="currentColor" strokeWidth="1.7" />
</svg>
);
}
function SpinnerIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.4" opacity="0.25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" />
</svg>
);
}
const inputBase =
"w-full rounded-xl border bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm outline-none transition-colors placeholder:text-slate-400 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus-visible:ring-offset-slate-900";
function Field(props: {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
error?: string;
type?: "text" | "email";
placeholder?: string;
autoComplete?: string;
inputMode?: "text" | "email" | "numeric";
maxLength?: number;
span2?: boolean;
}) {
const { id, label, value, onChange, error, type = "text", placeholder, autoComplete, inputMode, maxLength, span2 } = props;
const errId = `${id}-err`;
return (
<div className={span2 ? "sm:col-span-2" : undefined}>
<label htmlFor={id} className="mb-1.5 block text-xs font-medium text-slate-600 dark:text-slate-400">
{label}
</label>
<input
id={id}
type={type}
value={value}
placeholder={placeholder}
autoComplete={autoComplete}
inputMode={inputMode}
maxLength={maxLength}
onChange={(e) => onChange(e.target.value)}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errId : undefined}
className={
inputBase +
(error
? " border-rose-400 focus-visible:ring-rose-500 dark:border-rose-500/70"
: " border-slate-300 hover:border-slate-400 dark:border-slate-700 dark:hover:border-slate-600")
}
/>
{error ? (
<p id={errId} role="alert" className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">
{error}
</p>
) : null}
</div>
);
}
export default function StepCheckout() {
const reduce = useReducedMotion();
const uid = useId();
const [current, setCurrent] = useState<StepIndex>(0);
const [dir, setDir] = useState(1);
const [completed, setCompleted] = useState<Set<number>>(new Set());
const [data, setData] = useState<FormData>(INITIAL);
const [errors, setErrors] = useState<Errors>({});
const [status, setStatus] = useState<Status>("idle");
const [orderNo, setOrderNo] = useState("");
const [promo, setPromo] = useState("");
const [applied, setApplied] = useState(false);
const [promoMsg, setPromoMsg] = useState<{ ok: boolean; text: string } | null>(null);
const stepRefs = useRef<Array<HTMLButtonElement | null>>([]);
const headingRef = useRef<HTMLHeadingElement | null>(null);
const successRef = useRef<HTMLHeadingElement | null>(null);
const focusFlag = useRef(false);
const timer = useRef<number | null>(null);
const unlocked = useMemo(() => {
const arr = Array.from(completed);
const maxDone = arr.length ? Math.max(...arr) + 1 : 0;
return Math.min(STEPS.length - 1, Math.max(current, maxDone));
}, [completed, current]);
const totals = useMemo(() => {
const subtotal = ORDER.reduce((s, i) => s + i.price * i.qty, 0);
const discount = applied ? subtotal * 0.1 : 0;
const shipping = 12;
const taxable = subtotal - discount;
const tax = taxable * 0.0875;
const total = taxable + shipping + tax;
return { subtotal, discount, shipping, tax, total };
}, [applied]);
useEffect(() => {
if (focusFlag.current) {
focusFlag.current = false;
headingRef.current?.focus();
}
}, [current]);
useEffect(() => {
if (status === "done") successRef.current?.focus();
}, [status]);
useEffect(() => {
return () => {
if (timer.current !== null) window.clearTimeout(timer.current);
};
}, []);
function update<K extends keyof FormData>(key: K, value: string) {
setData((d) => ({ ...d, [key]: value }));
if (errors[key]) {
setErrors((e) => {
const n = { ...e };
delete n[key];
return n;
});
}
}
function markDone(step: number) {
setCompleted((prev) => {
const n = new Set(prev);
n.add(step);
return n;
});
}
function goNext() {
const errs = validateStep(current, data);
setErrors(errs);
if (Object.keys(errs).length) return;
markDone(current);
setDir(1);
setCurrent((c) => Math.min(c + 1, STEPS.length - 1) as StepIndex);
focusFlag.current = true;
}
function goBack() {
setErrors({});
setDir(-1);
setCurrent((c) => Math.max(c - 1, 0) as StepIndex);
focusFlag.current = true;
}
function goTo(target: number) {
if (target === current) return;
if (target < current) {
setErrors({});
setDir(-1);
setCurrent(target as StepIndex);
focusFlag.current = true;
return;
}
const errs = validateStep(current, data);
setErrors(errs);
if (Object.keys(errs).length) return;
markDone(current);
setDir(1);
setCurrent(target as StepIndex);
focusFlag.current = true;
}
function placeOrder() {
for (let s = 0; s < 3; s++) {
const errs = validateStep(s, data);
if (Object.keys(errs).length) {
setErrors(errs);
setDir(s < current ? -1 : 1);
setCurrent(s as StepIndex);
focusFlag.current = true;
return;
}
}
setStatus("placing");
timer.current = window.setTimeout(() => {
setOrderNo("NB-" + Math.random().toString(36).slice(2, 8).toUpperCase());
setStatus("done");
}, 1100);
}
function applyPromo() {
const code = promo.trim().toUpperCase();
if (!code) return;
if (code === "NORTH10") {
setApplied(true);
setPromoMsg({ ok: true, text: "NORTH10 applied — 10% off your items." });
} else {
setApplied(false);
setPromoMsg({ ok: false, text: "That code isn't valid. Try NORTH10." });
}
}
function reset() {
if (timer.current !== null) window.clearTimeout(timer.current);
setStatus("idle");
setCurrent(0);
setDir(1);
setCompleted(new Set());
setData(INITIAL);
setErrors({});
setPromo("");
setApplied(false);
setPromoMsg(null);
}
function handleStepKey(e: KeyboardEvent<HTMLButtonElement>, index: number) {
const enabled: number[] = [];
for (let i = 0; i < STEPS.length; i++) if (i <= unlocked) enabled.push(i);
const pos = enabled.indexOf(index);
let target: number | null = null;
if (e.key === "ArrowRight" || e.key === "ArrowDown") target = enabled[Math.min(pos + 1, enabled.length - 1)];
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") target = enabled[Math.max(pos - 1, 0)];
else if (e.key === "Home") target = enabled[0];
else if (e.key === "End") target = enabled[enabled.length - 1];
if (target !== null && target !== undefined) {
e.preventDefault();
stepRefs.current[target]?.focus();
}
}
const variants = {
enter: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * 28 }),
center: { opacity: 1, x: 0 },
exit: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * -28 }),
};
const last4 = onlyDigits(data.cardNumber).slice(-4);
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-4 py-16 text-slate-900 sm:py-24 dark:from-slate-950 dark:to-slate-900 dark:text-slate-100">
<style>{`
@keyframes stpk-spin { to { transform: rotate(360deg); } }
@keyframes stpk-pop {
0% { transform: scale(0.5); opacity: 0; }
60% { transform: scale(1.12); }
100% { transform: scale(1); opacity: 1; }
}
@keyframes stpk-draw { from { stroke-dashoffset: 30; } to { stroke-dashoffset: 0; } }
@keyframes stpk-ring {
0%, 100% { box-shadow: 0 0 0 0 rgba(99,102,241,0.32); }
50% { box-shadow: 0 0 0 6px rgba(99,102,241,0); }
}
.stpk-spin { animation: stpk-spin 0.75s linear infinite; }
.stpk-pop { animation: stpk-pop 0.32s cubic-bezier(0.34,1.56,0.64,1) both; }
.stpk-ring { animation: stpk-ring 2.2s ease-out infinite; }
.stpk-draw { stroke-dasharray: 30; animation: stpk-draw 0.5s ease-out 0.1s both; }
@media (prefers-reduced-motion: reduce) {
.stpk-spin, .stpk-pop, .stpk-ring, .stpk-draw { animation: none !important; }
.stpk-draw { stroke-dashoffset: 0 !important; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<header className="mb-8 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-[0.22em] text-indigo-600 dark:text-indigo-400">
Northbound Supply Co.
</p>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">Secure checkout</h2>
</div>
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-emerald-300 bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-300">
<LockIcon className="h-3.5 w-3.5" />
256-bit encrypted
</div>
</header>
<p className="sr-only" aria-live="polite">
Step {current + 1} of {STEPS.length}: {STEPS[current].label}
</p>
{/* Progress stepper */}
<nav aria-label="Checkout progress" className="mb-8">
<ol className="flex items-start">
{STEPS.map((step, i) => {
const done = completed.has(i) && i !== current;
const isCurrent = i === current && status !== "done";
const locked = i > unlocked;
const circleTone = done
? "border-emerald-500 bg-emerald-500 text-white"
: isCurrent
? "border-indigo-500 bg-white text-indigo-600 dark:bg-slate-900 dark:text-indigo-300"
: "border-slate-300 bg-white text-slate-400 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-500";
return (
<li key={step.label} className="flex flex-1 items-start last:flex-none">
<button
type="button"
ref={(el) => {
stepRefs.current[i] = el;
}}
onClick={() => goTo(i)}
onKeyDown={(e) => handleStepKey(e, i)}
disabled={locked}
aria-current={isCurrent ? "step" : undefined}
aria-label={`Step ${i + 1}, ${step.label}${done ? ", completed" : isCurrent ? ", current" : locked ? ", locked" : ""}`}
className="group flex flex-col items-center gap-2 rounded-xl px-1 py-1 text-center outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed dark:focus-visible:ring-offset-slate-900"
>
<span
className={
"relative flex h-10 w-10 items-center justify-center rounded-full border-2 text-sm font-semibold transition-colors " +
circleTone +
(isCurrent ? " stpk-ring" : "")
}
>
{done ? (
<CheckIcon className="stpk-pop h-5 w-5" />
) : (
<span>{i + 1}</span>
)}
</span>
<span className="flex flex-col">
<span
className={
"text-xs font-semibold sm:text-sm " +
(isCurrent
? "text-slate-900 dark:text-white"
: done
? "text-slate-700 dark:text-slate-300"
: "text-slate-400 dark:text-slate-500")
}
>
{step.label}
</span>
<span className="hidden text-[11px] text-slate-400 sm:block dark:text-slate-500">{step.hint}</span>
</span>
</button>
{i < STEPS.length - 1 ? (
<div className="mx-1 mt-5 h-1 flex-1 overflow-hidden rounded-full bg-slate-200 sm:mx-2 dark:bg-slate-800" aria-hidden="true">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500"
initial={false}
animate={{ width: current > i ? "100%" : "0%" }}
transition={{ duration: reduce ? 0 : 0.45, ease: "easeInOut" }}
/>
</div>
) : null}
</li>
);
})}
</ol>
</nav>
{status === "done" ? (
<div className="rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm sm:p-12 dark:border-slate-800 dark:bg-slate-900">
<span className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400">
<svg className="h-8 w-8" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path className="stpk-draw" d="M5 12.5l4.2 4.2L19 7" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<h3 ref={successRef} tabIndex={-1} className="text-xl font-bold outline-none sm:text-2xl">
Order confirmed
</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600 dark:text-slate-400">
Thanks, {data.fullName.split(" ")[0] || "friend"}. A receipt is on its way to{" "}
<span className="font-medium text-slate-800 dark:text-slate-200">{data.email}</span>.
</p>
<dl className="mx-auto mt-6 grid max-w-md grid-cols-2 gap-3 text-left">
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/50">
<dt className="text-[11px] uppercase tracking-wide text-slate-500 dark:text-slate-500">Order</dt>
<dd className="mt-0.5 font-mono text-sm font-semibold text-slate-900 dark:text-slate-100">{orderNo}</dd>
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/50">
<dt className="text-[11px] uppercase tracking-wide text-slate-500 dark:text-slate-500">Total paid</dt>
<dd className="mt-0.5 text-sm font-semibold text-slate-900 dark:text-slate-100">{money(totals.total)}</dd>
</div>
</dl>
<p className="mt-5 inline-flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<TruckIcon className="h-4 w-4" />
Estimated delivery Jul 21 – Jul 24 to {data.city || "your address"}
</p>
<div className="mt-7">
<button
type="button"
onClick={reset}
className="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-white px-5 py-2.5 text-sm font-semibold text-slate-700 shadow-sm outline-none transition-colors hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
Start a new order
</button>
</div>
</div>
) : (
<div className="grid gap-6 lg:grid-cols-5">
{/* Form panel */}
<div className="lg:col-span-3">
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-7 dark:border-slate-800 dark:bg-slate-900">
<AnimatePresence mode="wait" custom={dir} initial={false}>
<motion.div
key={current}
custom={dir}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: reduce ? 0 : 0.28, ease: "easeOut" }}
className="min-h-[19rem]"
>
<h3
ref={headingRef}
tabIndex={-1}
className="text-lg font-bold outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-4 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
>
{current === 0 && "Contact details"}
{current === 1 && "Shipping address"}
{current === 2 && "Payment"}
{current === 3 && "Review your order"}
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{current === 0 && "We'll use this to send your receipt and delivery updates."}
{current === 1 && "Where should we send your Northbound gear?"}
{current === 2 && "Your details are encrypted end-to-end. We never store your CVC."}
{current === 3 && "Check everything looks right, then place your order."}
</p>
<div className="mt-5">
{current === 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field
id={`${uid}-name`}
label="Full name"
value={data.fullName}
onChange={(v) => update("fullName", v)}
error={errors.fullName}
placeholder="Robin Ashcroft"
autoComplete="name"
span2
/>
<Field
id={`${uid}-email`}
label="Email address"
type="email"
inputMode="email"
value={data.email}
onChange={(v) => update("email", v)}
error={errors.email}
placeholder="robin@example.com"
autoComplete="email"
span2
/>
</div>
) : null}
{current === 1 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field
id={`${uid}-addr`}
label="Street address"
value={data.address}
onChange={(v) => update("address", v)}
error={errors.address}
placeholder="14 Harbour Lane"
autoComplete="street-address"
span2
/>
<Field
id={`${uid}-city`}
label="City / town"
value={data.city}
onChange={(v) => update("city", v)}
error={errors.city}
placeholder="Bristol"
autoComplete="address-level2"
/>
<Field
id={`${uid}-postal`}
label="Postal code"
value={data.postal}
onChange={(v) => update("postal", v)}
error={errors.postal}
placeholder="BS1 4TX"
autoComplete="postal-code"
/>
<div className="sm:col-span-2">
<label
htmlFor={`${uid}-country`}
className="mb-1.5 block text-xs font-medium text-slate-600 dark:text-slate-400"
>
Country
</label>
<select
id={`${uid}-country`}
value={data.country}
onChange={(e) => update("country", e.target.value)}
autoComplete="country-name"
aria-invalid={errors.country ? true : undefined}
aria-describedby={errors.country ? `${uid}-country-err` : undefined}
className={
inputBase +
" appearance-none bg-[length:1.1rem] bg-[right_0.75rem_center] bg-no-repeat pr-10 " +
(errors.country
? "border-rose-400 focus-visible:ring-rose-500 dark:border-rose-500/70"
: "border-slate-300 hover:border-slate-400 dark:border-slate-700 dark:hover:border-slate-600")
}
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E\")",
}}
>
<option value="" disabled>
Select a country…
</option>
{COUNTRIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
{errors.country ? (
<p id={`${uid}-country-err`} role="alert" className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">
{errors.country}
</p>
) : null}
</div>
</div>
) : null}
{current === 2 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field
id={`${uid}-cardname`}
label="Name on card"
value={data.cardName}
onChange={(v) => update("cardName", v)}
error={errors.cardName}
placeholder="Robin Ashcroft"
autoComplete="cc-name"
span2
/>
<div className="sm:col-span-2">
<label htmlFor={`${uid}-cardnum`} className="mb-1.5 block text-xs font-medium text-slate-600 dark:text-slate-400">
Card number
</label>
<div className="relative">
<input
id={`${uid}-cardnum`}
value={data.cardNumber}
onChange={(e) => update("cardNumber", formatCard(e.target.value))}
inputMode="numeric"
autoComplete="cc-number"
placeholder="4242 4242 4242 4242"
aria-invalid={errors.cardNumber ? true : undefined}
aria-describedby={errors.cardNumber ? `${uid}-cardnum-err` : undefined}
className={
inputBase +
" pr-11 font-mono tracking-wide " +
(errors.cardNumber
? "border-rose-400 focus-visible:ring-rose-500 dark:border-rose-500/70"
: "border-slate-300 hover:border-slate-400 dark:border-slate-700 dark:hover:border-slate-600")
}
/>
<CardIcon className="pointer-events-none absolute right-3.5 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400 dark:text-slate-500" />
</div>
{errors.cardNumber ? (
<p id={`${uid}-cardnum-err`} role="alert" className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">
{errors.cardNumber}
</p>
) : null}
</div>
<Field
id={`${uid}-exp`}
label="Expiry (MM/YY)"
value={data.expiry}
onChange={(v) => update("expiry", formatExpiry(v))}
error={errors.expiry}
inputMode="numeric"
placeholder="09/28"
autoComplete="cc-exp"
maxLength={5}
/>
<Field
id={`${uid}-cvc`}
label="CVC"
value={data.cvc}
onChange={(v) => update("cvc", onlyDigits(v).slice(0, 4))}
error={errors.cvc}
inputMode="numeric"
placeholder="123"
autoComplete="cc-csc"
maxLength={4}
/>
</div>
) : null}
{current === 3 ? (
<dl className="divide-y divide-slate-200 rounded-xl border border-slate-200 dark:divide-slate-800 dark:border-slate-800">
{[
{ k: "Contact", v: `${data.fullName} · ${data.email}`, step: 0 as StepIndex },
{
k: "Ship to",
v: `${data.address}, ${data.city} ${data.postal}, ${data.country}`,
step: 1 as StepIndex,
},
{
k: "Payment",
v: last4 ? `Card ending ${last4} · exp ${data.expiry}` : "—",
step: 2 as StepIndex,
},
].map((row) => (
<div key={row.k} className="flex items-start justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<dt className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-500">
{row.k}
</dt>
<dd className="mt-0.5 truncate text-sm text-slate-800 dark:text-slate-200">{row.v}</dd>
</div>
<button
type="button"
onClick={() => goTo(row.step)}
className="shrink-0 rounded-lg px-2 py-1 text-xs font-semibold text-indigo-600 outline-none transition-colors hover:text-indigo-700 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-400 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-slate-900"
>
Edit
</button>
</div>
))}
</dl>
) : null}
</div>
</motion.div>
</AnimatePresence>
{/* Footer nav */}
<div className="mt-7 flex items-center justify-between gap-3 border-t border-slate-200 pt-5 dark:border-slate-800">
<button
type="button"
onClick={goBack}
disabled={current === 0}
className="inline-flex items-center gap-1.5 rounded-xl px-3 py-2.5 text-sm font-semibold text-slate-600 outline-none transition-colors hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:pointer-events-none disabled:opacity-0 dark:text-slate-400 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
>
<ArrowIcon className="h-4 w-4 rotate-180" />
Back
</button>
{current < 3 ? (
<button
type="button"
onClick={goNext}
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-indigo-600 to-violet-600 px-6 py-2.5 text-sm font-semibold text-white shadow-sm outline-none transition-transform hover:from-indigo-500 hover:to-violet-500 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:scale-[0.98] dark:focus-visible:ring-offset-slate-900"
>
Continue
<ArrowIcon className="h-4 w-4" />
</button>
) : (
<button
type="button"
onClick={placeOrder}
disabled={status === "placing"}
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-emerald-600 to-emerald-500 px-6 py-2.5 text-sm font-semibold text-white shadow-sm outline-none transition-transform hover:from-emerald-500 hover:to-emerald-400 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:scale-[0.98] disabled:cursor-wait disabled:opacity-80 dark:focus-visible:ring-offset-slate-900"
>
{status === "placing" ? (
<>
<SpinnerIcon className="stpk-spin h-4 w-4" />
Processing…
</>
) : (
<>
<LockIcon className="h-4 w-4" />
Place order · {money(totals.total)}
</>
)}
</button>
)}
</div>
</div>
</div>
{/* Order summary */}
<aside className="lg:col-span-2">
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900">
<h3 className="text-sm font-bold uppercase tracking-wide text-slate-500 dark:text-slate-400">Order summary</h3>
<ul className="mt-4 space-y-3">
{ORDER.map((item) => (
<li key={item.name} className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<span className="mt-0.5 flex h-6 min-w-6 items-center justify-center rounded-full bg-slate-100 px-1.5 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{item.qty}
</span>
<div>
<p className="text-sm font-medium leading-tight text-slate-900 dark:text-slate-100">{item.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{item.variant}</p>
</div>
</div>
<span className="shrink-0 text-sm tabular-nums text-slate-700 dark:text-slate-300">
{money(item.price * item.qty)}
</span>
</li>
))}
</ul>
{/* Promo */}
<div className="mt-5 border-t border-slate-200 pt-4 dark:border-slate-800">
<label htmlFor={`${uid}-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={`${uid}-promo`}
value={promo}
onChange={(e) => setPromo(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyPromo();
}
}}
placeholder="NORTH10"
aria-describedby={promoMsg ? `${uid}-promo-msg` : undefined}
className={inputBase + " border-slate-300 uppercase tracking-wide hover:border-slate-400 dark:border-slate-700 dark:hover:border-slate-600"}
/>
<button
type="button"
onClick={applyPromo}
className="shrink-0 rounded-xl border border-slate-300 bg-slate-50 px-4 text-sm font-semibold text-slate-700 outline-none transition-colors hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
>
Apply
</button>
</div>
{promoMsg ? (
<p
id={`${uid}-promo-msg`}
role="status"
className={
"mt-1.5 text-xs font-medium " +
(promoMsg.ok ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400")
}
>
{promoMsg.text}
</p>
) : null}
</div>
{/* Totals */}
<dl className="mt-5 space-y-2 border-t border-slate-200 pt-4 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 text-slate-700 dark:text-slate-300">{money(totals.subtotal)}</dd>
</div>
{totals.discount > 0 ? (
<div className="flex justify-between">
<dt className="text-emerald-600 dark:text-emerald-400">Discount (10%)</dt>
<dd className="tabular-nums text-emerald-600 dark:text-emerald-400">−{money(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 text-slate-700 dark:text-slate-300">{money(totals.shipping)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-500 dark:text-slate-400">Tax (est.)</dt>
<dd className="tabular-nums text-slate-700 dark:text-slate-300">{money(totals.tax)}</dd>
</div>
<div className="flex items-baseline justify-between border-t border-slate-200 pt-3 dark:border-slate-800">
<dt className="text-base font-bold text-slate-900 dark:text-white">Total</dt>
<dd className="text-base font-bold tabular-nums text-slate-900 dark:text-white">{money(totals.total)}</dd>
</div>
</dl>
<p className="mt-4 flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<TruckIcon className="h-4 w-4 shrink-0" />
Free returns within 30 days · carbon-neutral delivery
</p>
</div>
</aside>
</div>
)}
</div>
</section>
);
}Dependencies
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 →
Horizontal Stepper
Originalhorizontal step indicator

Vertical Stepper
Originalvertical stepper

Numbered Stepper
Originalnumbered stepper with connectors
Icons Stepper
Originalicon stepper

Progress Stepper
Originalstepper with progress fill

Dots Stepper
Originaldot stepper

Wizard Stepper
Originalwizard stepper with next/back

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

