Web InnoventixFreeCode

Checkout Stepper

Original · free

checkout progress steps

byWeb InnoventixReact + Tailwind
stepcheckoutsteppers
Open

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
step-checkout.tsx
"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

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 quote

Similar components

Browse all →