# Checkout

> A one-page checkout that folds each finished step into a one-line summary: inline validation that clears as you type, delivery choices that re-total the order, a card that flips when you reach the CVC, and a calm confirmation.

A shadcn/ui block from the [@devl](https://devl.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/devl/checkout).

- Page, with a live preview: https://shadcn.exchange/r/devl/checkout
- Screenshot: https://shadcn.exchange/shot/devl/checkout/light.png
- Registry item: https://devl.dev/r/checkout.json
- Categories: screen, Commerce
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/devl/checkout.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `utils`, `https://coss.com/ui/r/button.json`, `https://coss.com/ui/r/checkbox.json`, `https://coss.com/ui/r/input.json`, `https://coss.com/ui/r/label.json`, `https://coss.com/ui/r/radio-group.json`, `https://coss.com/ui/r/select.json`

## Files

### `registry/devl/checkout.tsx`

```tsx
"use client";

import {
  CheckIcon,
  ChevronDownIcon,
  LockIcon,
  MinusIcon,
  NfcIcon,
  PackageIcon,
  PlusIcon,
  RabbitIcon,
  TagIcon,
  TruckIcon,
  Undo2Icon,
  XIcon,
  ZapIcon,
} from "lucide-react";
import {
  AnimatePresence,
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
} from "motion/react";
import { type FormEvent, type ReactNode, useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Radio, RadioGroup } from "@/components/ui/radio-group";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "@/components/ui/select";

export type CheckoutArt = "mug" | "tote" | "candle" | "notebook";

export interface CheckoutItem {
  id: string;
  name: string;
  variant: string;
  price: number;
  qty: number;
  art: CheckoutArt;
}

export interface CheckoutShipping {
  id: string;
  label: string;
  eta: string;
  price: number;
  icon: "standard" | "express" | "next";
}

export interface CheckoutProps {
  className?: string;
  storeName?: string;
  items?: CheckoutItem[];
  shippingOptions?: CheckoutShipping[];
  taxRate?: number;
  promoCodes?: Record<string, number>;
  testCard?: string;
  onTrackOrder?: (orderNo: string) => void;
}

type Step = 0 | 1 | 2 | 3;
type Values = {
  email: string;
  news: boolean;
  first: string;
  last: string;
  line1: string;
  line2: string;
  city: string;
  country: string;
  region: string;
  postcode: string;
  shipping: string;
  card: string;
  expiry: string;
  cvc: string;
  holder: string;
  sameBilling: boolean;
};
type Errors = Partial<Record<keyof Values, string>>;

const easeOut = [0.23, 1, 0.32, 1] as const;
const press = "transition-[color,background-color,box-shadow,scale,opacity] duration-150 ease-out active:scale-[0.97]";

const defaultItems: CheckoutItem[] = [
  { id: "i1", name: "Speckled stoneware mug", variant: "Oat · 350 ml", price: 28, qty: 2, art: "mug" },
  { id: "i2", name: "Waxed canvas tote", variant: "Moss", price: 64, qty: 1, art: "tote" },
  { id: "i3", name: "Cedar & smoke candle", variant: "220 g", price: 32, qty: 1, art: "candle" },
];

const defaultShipping: CheckoutShipping[] = [
  { id: "standard", label: "Standard", eta: "Thu 29 – Mon 2 Nov", price: 0, icon: "standard" },
  { id: "express", label: "Express", eta: "Tue 27 Oct", price: 9, icon: "express" },
  { id: "next", label: "Next day", eta: "Mon 26 Oct, order in 3h", price: 18, icon: "next" },
];

const countries: { value: string; label: string; regions?: string[]; regionLabel?: string; post: RegExp; postLabel: string; postHint: string }[] = [
  {
    value: "US",
    label: "United States",
    regions: ["California", "Colorado", "Illinois", "Massachusetts", "New York", "Oregon", "Texas", "Washington"],
    regionLabel: "State",
    post: /^\d{5}(-\d{4})?$/,
    postLabel: "ZIP code",
    postHint: "5 digits, like 97205",
  },
  {
    value: "CA",
    label: "Canada",
    regions: ["Alberta", "British Columbia", "Manitoba", "Nova Scotia", "Ontario", "Quebec"],
    regionLabel: "Province",
    post: /^[A-Za-z]\d[A-Za-z] ?\d[A-Za-z]\d$/,
    postLabel: "Postal code",
    postHint: "Like K1A 0B1",
  },
  {
    value: "GB",
    label: "United Kingdom",
    post: /^[A-Za-z]{1,2}\d[A-Za-z\d]? ?\d[A-Za-z]{2}$/,
    postLabel: "Postcode",
    postHint: "Like SW1A 1AA",
  },
  {
    value: "AU",
    label: "Australia",
    regions: ["New South Wales", "Queensland", "South Australia", "Tasmania", "Victoria", "Western Australia"],
    regionLabel: "State",
    post: /^\d{4}$/,
    postLabel: "Postcode",
    postHint: "4 digits",
  },
];

const steps = ["Contact", "Shipping address", "Delivery", "Payment"] as const;

const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const money = (n: number) => usd.format(n);

const digits = (s: string) => s.replace(/\D/g, "");
const formatCard = (s: string) => digits(s).slice(0, 16).replace(/(\d{4})(?=\d)/g, "$1 ");
const formatExpiry = (s: string, prev: string) => {
  const d = digits(s).slice(0, 4);
  if (s.length < prev.length && prev.endsWith(" / ") && d.length <= 2) return d.slice(0, 1);
  if (d.length >= 3) return `${d.slice(0, 2)} / ${d.slice(2)}`;
  if (d.length === 2) return `${d} / `;
  return d;
};
function luhn(num: string) {
  let sum = 0;
  let alt = false;
  for (let i = num.length - 1; i >= 0; i--) {
    let n = Number(num[i]);
    if (alt) {
      n *= 2;
      if (n > 9) n -= 9;
    }
    sum += n;
    alt = !alt;
  }
  return num.length >= 13 && sum % 10 === 0;
}

function validate(step: Step, v: Values): Errors {
  const e: Errors = {};
  if (step === 0) {
    if (!v.email.trim()) e.email = "Enter your email so we can send the receipt.";
    else if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v.email.trim())) e.email = "That email looks incomplete.";
  }
  if (step === 1) {
    const c = countries.find((x) => x.value === v.country);
    if (!v.first.trim()) e.first = "Required";
    if (!v.last.trim()) e.last = "Required";
    if (!v.line1.trim()) e.line1 = "Enter a street address.";
    if (!v.city.trim()) e.city = "Required";
    if (c?.regions && !v.region) e.region = `Pick a ${c.regionLabel?.toLowerCase()}.`;
    if (!v.postcode.trim()) e.postcode = "Required";
    else if (c && !c.post.test(v.postcode.trim())) e.postcode = c.postHint;
  }
  if (step === 3) {
    const d = digits(v.card);
    if (!d) e.card = "Enter a card number.";
    else if (!luhn(d)) e.card = "That number doesn’t look right.";
    const [mm, yy] = v.expiry.split(" / ").map(Number);
    if (!v.expiry) e.expiry = "Required";
    else if (!mm || mm > 12 || !yy || v.expiry.length < 7) e.expiry = "Use MM / YY";
    else if (2000 + yy < 2026 || (2000 + yy === 2026 && mm < 10)) e.expiry = "This card has expired.";
    if (!/^\d{3,4}$/.test(v.cvc)) e.cvc = "3 or 4 digits";
    if (!v.holder.trim()) e.holder = "Required";
  }
  return e;
}

function Tween({ value }: { value: number }) {
  const reduce = useReducedMotion();
  const mv = useMotionValue(value);
  const text = useTransform(mv, money);
  useEffect(() => {
    if (reduce) {
      mv.set(value);
      return;
    }
    const controls = animate(mv, value, { type: "spring", stiffness: 200, damping: 30 });
    return () => controls.stop();
  }, [value, reduce, mv]);
  return <motion.span className="tabular-nums">{text}</motion.span>;
}

function ProductArt({ art, className }: { art: CheckoutArt; className?: string }) {
  const tile: Record<CheckoutArt, string> = {
    mug: "bg-amber-500/14 dark:bg-amber-400/12",
    tote: "bg-emerald-600/14 dark:bg-emerald-400/12",
    candle: "bg-rose-500/12 dark:bg-rose-400/12",
    notebook: "bg-sky-500/12 dark:bg-sky-400/12",
  };
  return (
    <span aria-hidden className={cn("flex items-center justify-center overflow-hidden rounded-xl", tile[art], className)}>
      <svg className="size-[80%]" viewBox="0 0 48 48">
        {art === "mug" && (
          <>
            <path
              className="fill-none stroke-amber-800/70 dark:stroke-amber-200/70"
              d="M33 20h3.5a4.5 4.5 0 0 1 0 9H33"
              strokeWidth="3"
            />
            <path
              className="fill-amber-50 stroke-amber-900/30 dark:fill-amber-100/90 dark:stroke-transparent"
              d="M12 15h22v19a6 6 0 0 1-6 6H18a6 6 0 0 1-6-6z"
              strokeWidth="1.25"
            />
            <path className="fill-amber-800/15" d="M12 15h22v4H12z" />
            {[
              [17, 24],
              [24, 29],
              [28, 22],
              [20, 34],
              [29, 34],
              [15, 30],
            ].map(([x, y]) => (
              <circle className="fill-amber-900/40" cx={x} cy={y} key={`${x}-${y}`} r="0.9" />
            ))}
          </>
        )}
        {art === "tote" && (
          <>
            <path
              className="fill-none stroke-emerald-900/60 dark:stroke-emerald-200/70"
              d="M18 18v-4a6 6 0 0 1 12 0v4"
              strokeWidth="2.5"
            />
            <path className="fill-emerald-700 dark:fill-emerald-500" d="M11 18h26l-2 22H13z" />
            <path className="fill-emerald-900/25" d="M11 18h26l-.3 3H11.3z" />
            <rect className="fill-amber-100/90" height="5" rx="1" width="9" x="19.5" y="27" />
          </>
        )}
        {art === "candle" && (
          <>
            <path className="fill-amber-400 dark:fill-amber-300" d="M24 8c2.5 3 3 5 0 8-3-3-2.5-5 0-8z" />
            <rect className="fill-rose-950/60 dark:fill-rose-100/70" height="4" rx="0.5" width="1.5" x="23.25" y="15" />
            <rect className="fill-rose-200 dark:fill-rose-200/90" height="22" rx="5" width="22" x="13" y="18" />
            <rect className="fill-rose-900/20" height="2" width="22" x="13" y="22" />
            <rect className="fill-white/80 dark:fill-white/70" height="8" rx="1" width="12" x="18" y="27" />
          </>
        )}
        {art === "notebook" && (
          <>
            <rect className="fill-sky-700 dark:fill-sky-400" height="30" rx="2" width="22" x="13" y="9" />
            <rect className="fill-sky-900/30" height="30" width="3" x="13" y="9" />
            <rect className="fill-white/80" height="5" rx="1" width="10" x="20" y="16" />
          </>
        )}
      </svg>
    </span>
  );
}

function TextField({
  id,
  label,
  error,
  hint,
  optional,
  mono,
  className,
  ...props
}: {
  id: string;
  label: string;
  error?: string;
  hint?: string;
  optional?: boolean;
  mono?: boolean;
  className?: string;
} & Omit<React.ComponentProps<typeof Input>, "id" | "className">) {
  return (
    <div className={cn("flex min-w-0 flex-col gap-1.5", className)}>
      <Label htmlFor={id}>
        {label}
        {optional && <span className="font-normal text-muted-foreground">Optional</span>}
      </Label>
      <Input
        aria-describedby={error ? `${id}-err` : hint ? `${id}-hint` : undefined}
        aria-invalid={error ? true : undefined}
        className={cn(mono && "font-mono tabular-nums")}
        id={id}
        {...props}
      />
      <FieldNote error={error} hint={hint} id={id} />
    </div>
  );
}

function FieldNote({ id, error, hint }: { id: string; error?: string; hint?: string }) {
  return (
    <AnimatePresence initial={false} mode="popLayout">
      {error ? (
        <motion.p
          animate={{ opacity: 1, y: 0 }}
          className="text-destructive-foreground text-xs"
          exit={{ opacity: 0 }}
          id={`${id}-err`}
          initial={{ opacity: 0, y: -2 }}
          key="err"
          transition={{ duration: 0.15, ease: easeOut }}
        >
          {error}
        </motion.p>
      ) : hint ? (
        <p className="text-muted-foreground text-xs" id={`${id}-hint`} key="hint">
          {hint}
        </p>
      ) : null}
    </AnimatePresence>
  );
}

export function Checkout({
  className,
  storeName = "Hearth Goods",
  items: initialItems = defaultItems,
  shippingOptions = defaultShipping,
  taxRate = 0.0825,
  promoCodes = { HEARTH10: 0.1 },
  testCard = "4242 4242 4242 4242",
  onTrackOrder,
}: CheckoutProps) {
  const reduce = useReducedMotion();
  const uid = useId().replace(/:/g, "");
  const [items, setItems] = useState(initialItems);
  const [open, setOpen] = useState<Step>(0);
  const [done, setDone] = useState<Step[]>([]);
  const [errors, setErrors] = useState<Errors>({});
  const [submittedSteps, setSubmittedSteps] = useState<Step[]>([]);
  const [status, setStatus] = useState<"form" | "paying" | "placed">("form");
  const [summaryOpen, setSummaryOpen] = useState(false);
  const [flip, setFlip] = useState(false);
  const [promo, setPromo] = useState("");
  const [promoApplied, setPromoApplied] = useState<string | null>(null);
  const [promoError, setPromoError] = useState<string | null>(null);
  const formRef = useRef<HTMLDivElement>(null);
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const [v, setV] = useState<Values>({
    email: "",
    news: true,
    first: "",
    last: "",
    line1: "",
    line2: "",
    city: "",
    country: "US",
    region: "",
    postcode: "",
    shipping: shippingOptions[0]?.id ?? "",
    card: "",
    expiry: "",
    cvc: "",
    holder: "",
    sameBilling: true,
  });

  useEffect(() => () => {
    if (timer.current) clearTimeout(timer.current);
  }, []);

  const set = <K extends keyof Values>(k: K, value: Values[K]) => {
    setV((cur) => {
      const next = { ...cur, [k]: value };
      if (submittedSteps.includes(open)) setErrors(validate(open, next));
      return next;
    });
  };

  const country = countries.find((c) => c.value === v.country) ?? countries[0];
  const ship = shippingOptions.find((s) => s.id === v.shipping);
  const subtotal = items.reduce((s, i) => s + i.price * i.qty, 0);
  const discount = promoApplied ? subtotal * (promoCodes[promoApplied] ?? 0) : 0;
  const shippingCost = done.includes(2) || open >= 2 ? ship?.price ?? 0 : null;
  const tax = (subtotal - discount) * taxRate;
  const total = subtotal - discount + (shippingCost ?? 0) + tax;
  const count = items.reduce((s, i) => s + i.qty, 0);

  const focusFirstError = (errs: Errors) => {
    const first = Object.keys(errs)[0];
    if (!first) return;
    requestAnimationFrame(() => {
      const el = formRef.current?.querySelector<HTMLElement>(`[id="${uid}-${first}"]`);
      el?.focus();
    });
  };

  const submitStep = (step: Step) => (e: FormEvent) => {
    e.preventDefault();
    const errs = validate(step, v);
    setSubmittedSteps((t) => (t.includes(step) ? t : [...t, step]));
    setErrors(errs);
    if (Object.keys(errs).length) {
      focusFirstError(errs);
      return;
    }
    setDone((d) => (d.includes(step) ? d : [...d, step]));
    if (step < 3) {
      const next = ([0, 1, 2, 3] as Step[]).find((s) => s > step && !done.includes(s)) ?? ((step + 1) as Step);
      setOpen(next);
      setErrors({});
      return;
    }
    setStatus("paying");
    timer.current = setTimeout(() => setStatus("placed"), 1400);
  };

  const applyPromo = () => {
    const code = promo.trim().toUpperCase();
    if (!code) return;
    if (promoCodes[code] !== undefined) {
      setPromoApplied(code);
      setPromoError(null);
      setPromo("");
    } else {
      setPromoError("That code isn’t valid. Check for typos.");
    }
  };

  const reset = () => {
    setStatus("form");
    setOpen(0);
    setDone([]);
    setSubmittedSteps([]);
    setErrors({});
    setV((cur) => ({ ...cur, card: "", expiry: "", cvc: "", holder: "" }));
  };

  const summaryLine = (step: Step): ReactNode => {
    if (step === 0) return v.email;
    if (step === 1)
      return `${v.first} ${v.last}, ${v.line1}${v.line2 ? `, ${v.line2}` : ""}, ${v.city}${v.region ? `, ${v.region}` : ""} ${v.postcode.toUpperCase()}`;
    if (step === 2) return ship ? `${ship.label} · ${ship.price ? money(ship.price) : "Free"} · ${ship.eta}` : "";
    return `Card ending ${digits(v.card).slice(-4)}`;
  };

  const sectionBody = (step: Step) => {
    if (step === 0)
      return (
        <form className="flex flex-col gap-4" noValidate onSubmit={submitStep(0)}>
          <TextField
            autoComplete="email"
            error={errors.email}
            id={`${uid}-email`}
            inputMode="email"
            label="Email"
            onChange={(e) => set("email", e.target.value)}
            placeholder="you@example.com"
            type="email"
            value={v.email}
          />
          <Label className="font-normal">
            <Checkbox checked={v.news} onCheckedChange={(c) => set("news", Boolean(c))} />
            Send me the occasional note about new pieces
          </Label>
          <StepButton>Continue to shipping</StepButton>
        </form>
      );
    if (step === 1)
      return (
        <form className="grid grid-cols-2 gap-x-3 gap-y-4" noValidate onSubmit={submitStep(1)}>
          <div className="col-span-2 flex flex-col gap-1.5" data-field="country">
            <Label htmlFor={`${uid}-country`}>Country</Label>
            <Select
              items={countries.map((c) => ({ value: c.value, label: c.label }))}
              onValueChange={(val) => {
                if (!val) return;
                setV((cur) => ({ ...cur, country: val as string, region: "", postcode: "" }));
                setErrors((e) => ({ ...e, region: undefined, postcode: undefined }));
              }}
              value={v.country}
            >
              <SelectTrigger id={`${uid}-country`}>
                <SelectValue />
              </SelectTrigger>
              <SelectPopup>
                {countries.map((c) => (
                  <SelectItem key={c.value} value={c.value}>
                    {c.label}
                  </SelectItem>
                ))}
              </SelectPopup>
            </Select>
          </div>
          <TextField
            autoComplete="given-name"
            error={errors.first}
            id={`${uid}-first`}
            label="First name"
            onChange={(e) => set("first", e.target.value)}
            value={v.first}
          />
          <TextField
            autoComplete="family-name"
            error={errors.last}
            id={`${uid}-last`}
            label="Last name"
            onChange={(e) => set("last", e.target.value)}
            value={v.last}
          />
          <TextField
            autoComplete="address-line1"
            className="col-span-2"
            error={errors.line1}
            id={`${uid}-line1`}
            label="Address"
            onChange={(e) => set("line1", e.target.value)}
            placeholder="Street and number"
            value={v.line1}
          />
          <TextField
            autoComplete="address-line2"
            className="col-span-2"
            id={`${uid}-line2`}
            label="Apartment, suite"
            onChange={(e) => set("line2", e.target.value)}
            optional
            value={v.line2}
          />
          <TextField
            autoComplete="address-level2"
            className={cn(country.regions ? "col-span-2 @md:col-span-1" : "col-span-1")}
            error={errors.city}
            id={`${uid}-city`}
            label="City"
            onChange={(e) => set("city", e.target.value)}
            value={v.city}
          />
          {country.regions && (
            <div className="flex min-w-0 flex-col gap-1.5" data-field="region">
              <Label htmlFor={`${uid}-region`}>{country.regionLabel}</Label>
              <Select
                items={country.regions.map((r) => ({ value: r, label: r }))}
                onValueChange={(val) => set("region", (val as string) ?? "")}
                value={v.region || null}
              >
                <SelectTrigger aria-invalid={errors.region ? true : undefined} className="min-w-0" id={`${uid}-region`}>
                  <SelectValue placeholder="Select" />
                </SelectTrigger>
                <SelectPopup>
                  {country.regions.map((r) => (
                    <SelectItem key={r} value={r}>
                      {r}
                    </SelectItem>
                  ))}
                </SelectPopup>
              </Select>
              <FieldNote error={errors.region} id={`${uid}-region`} />
            </div>
          )}
          <TextField
            autoComplete="postal-code"
            error={errors.postcode}
            id={`${uid}-postcode`}
            label={country.postLabel}
            onChange={(e) => set("postcode", e.target.value)}
            value={v.postcode}
          />
          <div className="col-span-2">
            <StepButton>Continue to delivery</StepButton>
          </div>
        </form>
      );
    if (step === 2)
      return (
        <form className="flex flex-col gap-4" noValidate onSubmit={submitStep(2)}>
          <RadioGroup
            aria-label="Delivery speed"
            className="gap-2"
            onValueChange={(val) => set("shipping", val as string)}
            value={v.shipping}
          >
            {shippingOptions.map((s) => {
              const Icon = s.icon === "next" ? ZapIcon : s.icon === "express" ? RabbitIcon : TruckIcon;
              const on = v.shipping === s.id;
              return (
                <Label
                  className={cn(
                    "relative flex cursor-pointer items-center gap-3 rounded-xl border p-3 font-normal transition-[border-color,background-color,box-shadow] duration-150",
                    on ? "border-foreground/40 bg-accent/40 ring-1 ring-foreground/10" : "hover:bg-accent/40",
                  )}
                  key={s.id}
                >
                  <Radio value={s.id} />
                  <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
                    <Icon className="size-4" />
                  </span>
                  <span className="flex min-w-0 flex-1 flex-col gap-0.5">
                    <span className="font-medium">{s.label}</span>
                    <span className="truncate text-muted-foreground text-xs">{s.eta}</span>
                  </span>
                  <span className={cn("font-medium tabular-nums", !s.price && "text-success-foreground")}>
                    {s.price ? money(s.price) : "Free"}
                  </span>
                </Label>
              );
            })}
          </RadioGroup>
          <StepButton>Continue to payment</StepButton>
        </form>
      );
    return (
      <form aria-busy={status === "paying"} noValidate onSubmit={submitStep(3)}>
        <fieldset className="flex min-w-0 flex-col gap-4" disabled={status !== "form"}>
        <div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl border border-warning/30 border-dashed bg-warning/6 px-3 py-2 text-xs dark:bg-warning/10">
          <span className="font-medium text-warning-foreground">Test mode</span>
          <span className="text-muted-foreground">No real charge. Use any future date and any CVC.</span>
          <button
            className={cn(
              "relative ms-auto cursor-pointer rounded-md font-medium underline-offset-2 outline-none after:absolute after:-inset-2 after:content-[''] hover:underline focus-visible:ring-2 focus-visible:ring-ring",
              press,
            )}
            onClick={() => {
              const next = { ...v, card: testCard, expiry: "08 / 29", cvc: "123", holder: v.holder || `${v.first} ${v.last}`.trim() || "Rowan Ellery" };
              setV(next);
              if (submittedSteps.includes(3)) setErrors(validate(3, next));
            }}
            type="button"
          >
            Fill test card
          </button>
        </div>

        <div className="grid gap-4 @lg:grid-cols-[minmax(0,1fr)_200px] @lg:items-start">
          <div className="grid grid-cols-2 gap-x-3 gap-y-4">
            <TextField
              autoComplete="cc-number"
              className="col-span-2"
              mono
              error={errors.card}
              id={`${uid}-card`}
              inputMode="numeric"
              label="Card number"
              onChange={(e) => set("card", formatCard(e.target.value))}
              placeholder="1234 1234 1234 1234"
              value={v.card}
            />
            <TextField
              autoComplete="cc-exp"
              mono
              error={errors.expiry}
              id={`${uid}-expiry`}
              inputMode="numeric"
              label="Expiry"
              onChange={(e) => set("expiry", formatExpiry(e.target.value, v.expiry))}
              placeholder="MM / YY"
              value={v.expiry}
            />
            <TextField
              autoComplete="cc-csc"
              mono
              error={errors.cvc}
              id={`${uid}-cvc`}
              inputMode="numeric"
              label="CVC"
              onBlur={() => setFlip(false)}
              onChange={(e) => set("cvc", digits(e.target.value).slice(0, 4))}
              onFocus={() => setFlip(true)}
              placeholder="123"
              value={v.cvc}
            />
            <TextField
              autoComplete="cc-name"
              className="col-span-2"
              error={errors.holder}
              id={`${uid}-holder`}
              label="Name on card"
              onChange={(e) => set("holder", e.target.value)}
              value={v.holder}
            />
          </div>
          <CardPreview cvc={v.cvc} expiry={v.expiry} flipped={flip} holder={v.holder} number={v.card} />
        </div>

        <Label className="font-normal">
          <Checkbox checked={v.sameBilling} onCheckedChange={(c) => set("sameBilling", Boolean(c))} />
          Billing address is the same as shipping
        </Label>
        <Button className={cn("h-10 w-full text-sm sm:h-10", press)} loading={status === "paying"} size="lg" type="submit">
          <LockIcon />
          Pay <Tween value={total} />
        </Button>
        </fieldset>
      </form>
    );
  };

  const summary = (
    <div className="flex flex-col gap-5">
      <ul className="flex flex-col gap-4">
        <AnimatePresence initial={false}>
          {items.map((i) => (
            <motion.li
              animate={{ opacity: 1, height: "auto" }}
              className="flex items-center gap-3"
              exit={{ opacity: 0, height: 0 }}
              key={i.id}
              transition={{ duration: 0.2, ease: easeOut }}
            >
              <ProductArt art={i.art} className="size-16 shrink-0 border border-border/60" />
              <div className="flex min-w-0 flex-1 flex-col gap-1">
                <span className="truncate font-medium">{i.name}</span>
                <span className="truncate text-muted-foreground text-xs">{i.variant}</span>
                <div className="flex items-center gap-1">
                  <Button
                    aria-label={`One fewer ${i.name}`}
                    className={press}
                    disabled={i.qty <= 1 || status !== "form"}
                    onClick={() => setItems((all) => all.map((x) => (x.id === i.id ? { ...x, qty: x.qty - 1 } : x)))}
                    size="icon-xs"
                    variant="outline"
                  >
                    <MinusIcon />
                  </Button>
                  <span className="w-6 text-center text-xs tabular-nums">{i.qty}</span>
                  <Button
                    aria-label={`One more ${i.name}`}
                    className={press}
                    disabled={i.qty >= 9 || status !== "form"}
                    onClick={() => setItems((all) => all.map((x) => (x.id === i.id ? { ...x, qty: x.qty + 1 } : x)))}
                    size="icon-xs"
                    variant="outline"
                  >
                    <PlusIcon />
                  </Button>
                </div>
              </div>
              <span className="self-start font-medium tabular-nums">{money(i.price * i.qty)}</span>
            </motion.li>
          ))}
        </AnimatePresence>
      </ul>

      <div className="flex flex-col gap-1.5">
        <AnimatePresence initial={false} mode="popLayout">
          {promoApplied ? (
            <motion.div
              animate={{ opacity: 1, scale: 1 }}
              className="flex items-center gap-2"
              exit={{ opacity: 0, scale: 0.97 }}
              initial={{ opacity: 0, scale: 0.97 }}
              key="applied"
              transition={{ duration: 0.18, ease: easeOut }}
            >
              <span className="flex h-7 items-center gap-1.5 rounded-lg bg-success/10 ps-2 pe-1 font-medium font-mono text-success-foreground text-xs dark:bg-success/16">
                <TagIcon className="size-3.5" />
                {promoApplied}
                <button
                  aria-label={`Remove code ${promoApplied}`}
                  className="relative flex size-5 cursor-pointer items-center justify-center rounded-md outline-none after:absolute after:-inset-1.5 after:content-[''] hover:bg-success/16 focus-visible:ring-2 focus-visible:ring-ring"
                  disabled={status !== "form"}
                  onClick={() => setPromoApplied(null)}
                  type="button"
                >
                  <XIcon className="size-3" />
                </button>
              </span>
              <span className="text-muted-foreground text-xs">{Math.round((promoCodes[promoApplied] ?? 0) * 100)}% off your order</span>
            </motion.div>
          ) : (
            <motion.form
              animate={{ opacity: 1 }}
              className="flex gap-2"
              exit={{ opacity: 0 }}
              initial={{ opacity: 0 }}
              key="field"
              onSubmit={(e) => {
                e.preventDefault();
                applyPromo();
              }}
              transition={{ duration: 0.15 }}
            >
              <Input
                aria-describedby={promoError ? `${uid}-promo-err` : undefined}
                aria-invalid={promoError ? true : undefined}
                aria-label="Discount code"
                className="flex-1 uppercase placeholder:normal-case"
                onChange={(e) => {
                  setPromo(e.target.value);
                  setPromoError(null);
                }}
                placeholder="Discount code"
                value={promo}
              />
              <Button className={press} disabled={!promo.trim()} type="submit" variant="outline">
                Apply
              </Button>
            </motion.form>
          )}
        </AnimatePresence>
        {promoError && (
          <p className="text-destructive-foreground text-xs" id={`${uid}-promo-err`}>
            {promoError}
          </p>
        )}
      </div>

      <dl className="flex flex-col gap-2 border-t pt-4">
        <div className="flex justify-between gap-3">
          <dt className="text-muted-foreground">Subtotal · {count} items</dt>
          <dd className="tabular-nums">
            <Tween value={subtotal} />
          </dd>
        </div>
        <AnimatePresence initial={false}>
          {discount > 0 && (
            <motion.div
              animate={{ opacity: 1, height: "auto" }}
              className="overflow-hidden"
              exit={{ opacity: 0, height: 0 }}
              initial={{ opacity: 0, height: 0 }}
              transition={reduce ? { duration: 0 } : { duration: 0.2, ease: easeOut }}
            >
              <div className="flex justify-between gap-3 text-success-foreground">
                <dt>Discount</dt>
                <dd className="tabular-nums">
                  −<Tween value={discount} />
                </dd>
              </div>
            </motion.div>
          )}
        </AnimatePresence>
        <div className="flex justify-between gap-3">
          <dt className="text-muted-foreground">Shipping</dt>
          <dd className={cn("tabular-nums", shippingCost === null && "text-muted-foreground")}>
            {shippingCost === null ? "Next step" : shippingCost === 0 ? "Free" : <Tween value={shippingCost} />}
          </dd>
        </div>
        <div className="flex justify-between gap-3">
          <dt className="text-muted-foreground">Estimated tax</dt>
          <dd className="tabular-nums">
            <Tween value={tax} />
          </dd>
        </div>
        <div className="mt-2 flex items-baseline justify-between gap-3 border-t pt-4">
          <dt className="font-medium">Total</dt>
          <dd className="font-heading font-semibold text-xl tracking-tight">
            <span className="me-1.5 font-normal font-sans text-muted-foreground text-xs">USD</span>
            <Tween value={total} />
          </dd>
        </div>
      </dl>
      <p className="flex items-center gap-2 text-muted-foreground text-xs">
        <Undo2Icon className="size-3.5" /> Free returns within 30 days of delivery.
      </p>
    </div>
  );

  const orderNo = "HG-48213";

  return (
    <div
      className={cn(
        "@container relative isolate flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground text-sm",
        className,
      )}
    >
      <header className="flex h-14 shrink-0 border-b">
        <div className="flex min-w-0 flex-1 basis-0 items-center">
          <div className="mx-auto flex w-full max-w-[600px] items-center gap-2.5 px-4 @3xl:px-10 @4xl:me-0">
            <span aria-hidden className="flex size-7 items-center justify-center rounded-full bg-primary text-primary-foreground">
              <svg className="size-3.5" fill="none" viewBox="0 0 16 16">
                <path d="M3 13V7.5L8 3l5 4.5V13" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.6" />
                <path d="M6.5 13v-2.5a1.5 1.5 0 0 1 3 0V13" stroke="currentColor" strokeWidth="1.6" />
              </svg>
            </span>
            <span className="font-heading font-semibold">{storeName}</span>
            <span className="ms-auto flex items-center gap-1.5 text-muted-foreground text-xs @4xl:hidden">
              <LockIcon className="size-3.5" /> Secure checkout
            </span>
          </div>
        </div>
        <div className="hidden min-w-0 flex-1 basis-0 items-center border-l bg-muted/40 @4xl:flex">
          <div className="flex w-full max-w-[440px] items-center px-10">
            <span className="flex items-center gap-1.5 text-muted-foreground text-xs">
              <LockIcon className="size-3.5" /> Secure checkout
            </span>
          </div>
        </div>
      </header>

      <div className="shrink-0 border-b bg-muted/40 @4xl:hidden">
        <button
          aria-controls={`${uid}-summary`}
          aria-expanded={summaryOpen}
          className="flex h-12 w-full cursor-pointer items-center gap-2 px-4 text-left outline-none focus-visible:bg-accent"
          onClick={() => setSummaryOpen((o) => !o)}
          type="button"
        >
          <PackageIcon className="size-4 text-muted-foreground" />
          <span className="flex-1">{summaryOpen ? "Hide" : "Show"} order summary</span>
          <motion.span animate={{ rotate: summaryOpen ? 180 : 0 }} transition={{ duration: 0.2, ease: easeOut }}>
            <ChevronDownIcon className="size-4 text-muted-foreground" />
          </motion.span>
          <span className="font-medium">
            <Tween value={total} />
          </span>
        </button>
      </div>

      <div className="relative flex min-h-0 flex-1">
        <AnimatePresence initial={false}>
          {summaryOpen && (
            <>
              <motion.div
                animate={{ opacity: 1 }}
                aria-hidden
                className="absolute inset-0 z-20 bg-black/24 @4xl:hidden"
                exit={{ opacity: 0 }}
                initial={{ opacity: 0 }}
                onClick={() => setSummaryOpen(false)}
                transition={{ duration: 0.2 }}
              />
              <motion.div
                animate={{ clipPath: "inset(0 0 0% 0)" }}
                className="absolute inset-x-0 top-0 z-30 max-h-full overflow-y-auto border-b bg-background p-4 shadow-lg/5 @4xl:hidden"
                exit={{ clipPath: "inset(0 0 100% 0)" }}
                id={`${uid}-summary`}
                initial={{ clipPath: "inset(0 0 100% 0)" }}
                transition={reduce ? { duration: 0 } : { duration: 0.28, ease: easeOut }}
              >
                {summary}
              </motion.div>
            </>
          )}
        </AnimatePresence>

        <div className="min-w-0 flex-1 basis-0 overflow-y-auto" ref={formRef}>
          <div className="mx-auto flex max-w-[600px] flex-col gap-3 px-4 py-6 @3xl:px-10 @3xl:py-10 @4xl:me-0">
            <AnimatePresence initial={false} mode="wait">
              {status === "placed" ? (
                <Placed
                  email={v.email}
                  eta={ship?.eta ?? ""}
                  key="placed"
                  onReset={reset}
                  onTrack={onTrackOrder ? () => onTrackOrder(orderNo) : undefined}
                  orderNo={orderNo}
                  address={summaryLine(1)}
                  paid={`${money(total)} · ${summaryLine(3)}`}
                />
              ) : (
                <motion.div
                  className="flex flex-col gap-3"
                  exit={{ opacity: 0, y: -8 }}
                  key="form"
                  transition={{ duration: 0.18, ease: easeOut }}
                >
                  <div className="mb-3 flex flex-col gap-1">
                    <h2 className="text-balance font-heading font-semibold text-2xl tracking-tight">Checkout</h2>
                    <p className="text-muted-foreground tabular-nums">
                      Step {Math.min(open + 1, 4)} of 4 · {steps[open]}
                    </p>
                  </div>
                  <div className="divide-y overflow-hidden rounded-xl border bg-card shadow-xs/5">
                  {([0, 1, 2, 3] as Step[]).map((s) => {
                    const isOpen = open === s;
                    const isDone = done.includes(s) && !isOpen;
                    const reachable = s === 0 || done.includes((s - 1) as Step) || done.includes(s);
                    return (
                      <section
                        aria-labelledby={`${uid}-h${s}`}
                        className={cn("transition-[opacity] duration-200", !isOpen && !isDone && "opacity-56")}
                        key={s}
                      >
                        <div className="flex items-center gap-3 p-4">
                          <span
                            className={cn(
                              "flex size-6 shrink-0 items-center justify-center rounded-full font-medium text-xs tabular-nums transition-colors duration-200",
                              isDone ? "bg-primary text-primary-foreground" : isOpen ? "border border-foreground/72 text-foreground" : "border text-muted-foreground",
                            )}
                          >
                            <AnimatePresence initial={false} mode="popLayout">
                              {isDone ? (
                                <motion.span
                                  animate={{ opacity: 1, scale: 1 }}
                                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}
                                  key="check"
                                  transition={{ duration: 0.18, ease: easeOut }}
                                >
                                  <CheckIcon className="size-3.5" />
                                </motion.span>
                              ) : (
                                <motion.span animate={{ opacity: 1 }} initial={{ opacity: 0 }} key="n">
                                  {s + 1}
                                </motion.span>
                              )}
                            </AnimatePresence>
                          </span>
                          <div className="flex min-w-0 flex-1 flex-col">
                            <h3 className="font-medium" id={`${uid}-h${s}`}>
                              {steps[s]}
                            </h3>
                            {isDone && <p className="truncate text-muted-foreground text-xs">{summaryLine(s)}</p>}
                          </div>
                          {isDone && reachable && status === "form" && (
                            <Button
                              className={press}
                              onClick={() => {
                                setOpen(s);
                                setErrors({});
                              }}
                              size="sm"
                              variant="ghost"
                            >
                              Edit
                            </Button>
                          )}
                        </div>
                        <AnimatePresence initial={false}>
                          {isOpen && (
                            <motion.div
                              animate={{ height: "auto", opacity: 1 }}
                              className="overflow-hidden"
                              exit={{ height: 0, opacity: 0 }}
                              initial={{ height: 0, opacity: 0 }}
                              transition={reduce ? { duration: 0 } : { duration: 0.26, ease: easeOut }}
                            >
                              <div className="px-4 pt-0 pb-4">{sectionBody(s)}</div>
                            </motion.div>
                          )}
                        </AnimatePresence>
                      </section>
                    );
                  })}
                  </div>
                  <p className="mt-2 flex items-center justify-center gap-1.5 text-muted-foreground text-xs">
                    <LockIcon className="size-3" /> Payments are encrypted and processed securely.
                  </p>
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        </div>

        <aside aria-label="Order summary" className="hidden min-w-0 flex-1 basis-0 overflow-y-auto border-l bg-muted/40 @4xl:block">
          <div className="w-full max-w-[440px] px-10 py-10">
            <h2 className="mb-5 font-medium">Order summary</h2>
            {summary}
          </div>
        </aside>
      </div>
    </div>
  );
}

function StepButton({ children }: { children: ReactNode }) {
  return (
    <Button className={cn("h-10 w-full text-sm sm:h-10", press)} size="lg" type="submit">
      {children}
    </Button>
  );
}

function CardPreview({
  number,
  holder,
  expiry,
  cvc,
  flipped,
}: {
  number: string;
  holder: string;
  expiry: string;
  cvc: string;
  flipped: boolean;
}) {
  const reduce = useReducedMotion();
  const d = digits(number).padEnd(16, "•");
  const groups = [d.slice(0, 4), d.slice(4, 8), d.slice(8, 12), d.slice(12, 16)];
  return (
    <div aria-hidden className="mx-auto w-full max-w-[260px] [perspective:900px] @lg:mt-6">
      <motion.div
        animate={{ rotateY: flipped && !reduce ? 180 : 0 }}
        className="relative aspect-[1.586] w-full"
        initial={false}
        style={{ transformStyle: "preserve-3d" }}
        transition={{ duration: 0.45, ease: [0.645, 0.045, 0.355, 1] }}
      >
        <div
          className="absolute inset-0 flex flex-col justify-between overflow-hidden rounded-xl bg-linear-to-br from-neutral-700 via-neutral-800 to-neutral-950 p-4 text-white shadow-lg/16 ring-1 ring-white/10"
          style={{ backfaceVisibility: "hidden" }}
        >
          <span className="pointer-events-none absolute -top-16 -right-12 size-44 rounded-full bg-white/6" />
          <div className="relative flex items-center justify-between">
            <span className="h-6 w-8 rounded-md bg-gradient-to-br from-amber-200 to-amber-400 ring-1 ring-black/10" />
            <NfcIcon className="size-5 opacity-80" />
          </div>
          <div className="relative flex justify-between font-mono text-[15px] tabular-nums tracking-wider">
            {groups.map((g, i) => (
              <span key={i}>{g}</span>
            ))}
          </div>
          <div className="relative flex items-end justify-between gap-3 text-[10px] uppercase tracking-wider">
            <span className="flex min-w-0 flex-col gap-0.5">
              <span className="opacity-60">Card holder</span>
              <span className="truncate font-medium text-xs normal-case tracking-normal">{holder || "Your name"}</span>
            </span>
            <span className="flex shrink-0 flex-col items-end gap-0.5">
              <span className="opacity-60">Expires</span>
              <span className="font-medium font-mono text-xs tracking-normal">{expiry || "MM / YY"}</span>
            </span>
          </div>
        </div>
        <div
          className="absolute inset-0 flex flex-col overflow-hidden rounded-xl bg-linear-to-br from-neutral-800 to-neutral-950 pt-5 text-white shadow-lg/16 ring-1 ring-white/10"
          style={{ backfaceVisibility: "hidden", transform: "rotateY(180deg)" }}
        >
          <span className="h-9 w-full bg-black/70" />
          <div className="mt-4 flex items-center gap-2 px-4">
            <span className="h-7 flex-1 rounded-sm bg-white/85" />
            <span className="flex h-7 w-12 items-center justify-center rounded-sm bg-white font-mono text-indigo-950 text-sm tabular-nums">
              {cvc || "•••"}
            </span>
          </div>
        </div>
      </motion.div>
    </div>
  );
}

function Placed({
  orderNo,
  email,
  eta,
  address,
  paid,
  onReset,
  onTrack,
}: {
  orderNo: string;
  email: string;
  eta: string;
  address: ReactNode;
  paid: string;
  onReset: () => void;
  onTrack?: () => void;
}) {
  const reduce = useReducedMotion();
  const rise = (i: number) => ({
    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 8 },
    animate: { opacity: 1, y: 0 },
    transition: { duration: 0.3, ease: easeOut, delay: 0.25 + i * 0.04 },
  });
  return (
    <motion.div
      animate={{ opacity: 1 }}
      className="flex flex-col items-start gap-5 pt-4 @3xl:pt-10"
      initial={{ opacity: 0 }}
      role="status"
      transition={{ duration: 0.2 }}
    >
      <motion.span
        animate={{ scale: 1, opacity: 1 }}
        className="flex size-14 items-center justify-center rounded-full bg-success text-white"
        initial={reduce ? { opacity: 0 } : { scale: 0.85, opacity: 0 }}
        transition={{ type: "spring", stiffness: 420, damping: 24 }}
      >
        <svg aria-hidden className="size-7" fill="none" viewBox="0 0 24 24">
          <motion.path
            animate={{ pathLength: 1 }}
            d="M5.5 12.5 10 17 18.5 7.5"
            initial={{ pathLength: reduce ? 1 : 0 }}
            stroke="currentColor"
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={2.5}
            transition={{ duration: 0.3, ease: easeOut, delay: 0.15 }}
          />
        </svg>
      </motion.span>
      <motion.div className="flex flex-col gap-1" {...rise(0)}>
        <p className="font-mono text-muted-foreground text-xs">Order {orderNo}</p>
        <h2 className="text-balance font-heading font-semibold text-2xl tracking-tight">Thank you, it’s on its way soon</h2>
        <p className="text-pretty text-muted-foreground">
          A receipt is heading to <span className="text-foreground">{email}</span>. We’ll send tracking as soon as your parcel leaves the workshop.
        </p>
      </motion.div>
      <motion.dl className="grid w-full gap-px overflow-hidden rounded-xl border bg-border @md:grid-cols-2" {...rise(1)}>
        <div className="flex flex-col gap-1 bg-card p-4">
          <dt className="text-muted-foreground text-xs">Paid</dt>
          <dd className="font-medium tabular-nums">{paid}</dd>
        </div>
        <div className="flex min-w-0 flex-col gap-1 bg-card p-4">
          <dt className="text-muted-foreground text-xs">Shipping to</dt>
          <dd className="text-pretty font-medium">{address}</dd>
        </div>
      </motion.dl>
      <motion.ol aria-label="What happens next" className="flex w-full flex-col" {...rise(2)}>
        {[
          { title: "Order placed", meta: "Just now", done: true },
          { title: "Packed at the workshop", meta: "Usually within a day" },
          { title: "Handed to the courier", meta: "Tracking link by email" },
          { title: "Delivered", meta: `Expected ${eta}` },
        ].map((step, i, all) => (
          <li className="relative flex gap-3 pb-4 last:pb-0" key={step.title}>
            {i < all.length - 1 && (
              <span aria-hidden className="absolute top-5 bottom-0 left-[7px] w-px bg-border" />
            )}
            <span
              aria-hidden
              className={cn(
                "relative mt-0.5 flex size-[15px] shrink-0 items-center justify-center rounded-full",
                step.done ? "bg-success text-white" : "border-2 border-border bg-background",
              )}
            >
              {step.done && <CheckIcon className="size-2.5" strokeWidth={3} />}
            </span>
            <span className="flex min-w-0 flex-col gap-0.5">
              <span className={cn("font-medium", !step.done && "text-muted-foreground")}>{step.title}</span>
              <span className="text-muted-foreground text-xs">{step.meta}</span>
            </span>
          </li>
        ))}
      </motion.ol>
      <motion.div className="flex w-full flex-col gap-2 @md:flex-row" {...rise(3)}>
        {onTrack && (
          <Button className={cn("@md:flex-none", press)} onClick={onTrack} size="lg">
            <TruckIcon /> Track order
          </Button>
        )}
        <Button className={press} onClick={onReset} size="lg" variant={onTrack ? "outline" : "default"}>
          Start a new checkout
        </Button>
      </motion.div>
    </motion.div>
  );
}
```
