E-commerce

Checkout Flow

A multi-step checkout (contact, shipping, payment, review) with a progress header, direction-aware panel transitions, and a live order summary.

Install

npx shadcn@latest add @paragon/checkout-flow

Also installs: button, shipping-selector

checkout-flow.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronLeft, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
import {
  ShippingSelector,
  type ShippingMethod,
} from "@/registry/paragon/ui/shipping-selector";

export interface CheckoutLine {
  name: string;
  qty: number;
  price: number;
  hue?: number;
}

export interface CheckoutFlowProps extends React.ComponentProps<"div"> {
  items: CheckoutLine[];
  shippingMethods?: ShippingMethod[];
  currency?: string;
  locale?: Intl.LocalesArgument;
  onComplete?: () => void;
}

const STEPS = ["Contact", "Shipping", "Payment", "Review"] as const;
type Step = (typeof STEPS)[number];

const DEFAULT_SHIPPING: ShippingMethod[] = [
  { id: "standard", label: "Standard", eta: "Arrives Jul 11 – 14", price: 0, note: "Ground" },
  { id: "expedited", label: "Expedited", eta: "Arrives Jul 9", price: 9.99 },
  { id: "overnight", label: "Overnight", eta: "Arrives tomorrow", price: 24.99 },
];

function placeholder(hue: number) {
  return `linear-gradient(135deg, hsl(${hue} 55% 82%), hsl(${(hue + 40) % 360} 60% 66%))`;
}

/**
 * A multi-step checkout: contact → shipping → payment → review. A progress
 * header shows completed steps with a check and the active step highlighted;
 * panels cross-fade and slide by step direction (AnimatePresence, direction-
 * aware). The order summary tracks the selected shipping method with a tabular
 * total. Reduced motion swaps panels with opacity only.
 */
export function CheckoutFlow({
  items,
  shippingMethods = DEFAULT_SHIPPING,
  currency = "USD",
  locale = "en-US",
  onComplete,
  className,
  ...props
}: CheckoutFlowProps) {
  const reduced = useReducedMotion();
  const [step, setStep] = React.useState(0);
  const [dir, setDir] = React.useState<1 | -1>(1);
  const [shipId, setShipId] = React.useState(shippingMethods[0]?.id);
  const [placed, setPlaced] = React.useState(false);

  const format = React.useMemo(
    () => new Intl.NumberFormat(locale, { style: "currency", currency }),
    [locale, currency],
  );

  const subtotal = items.reduce((s, i) => s + i.price * i.qty, 0);
  const ship = shippingMethods.find((m) => m.id === shipId);
  const shipCost = ship?.price ?? 0;
  const tax = subtotal * 0.0825;
  const total = subtotal + shipCost + tax;

  const go = (next: number) => {
    setDir(next > step ? 1 : -1);
    setStep(Math.max(0, Math.min(STEPS.length - 1, next)));
  };

  const variants = {
    enter: (d: 1 | -1) =>
      reduced ? { opacity: 0 } : { opacity: 0, x: d * 24, filter: "blur(4px)" },
    center: { opacity: 1, x: 0, filter: "blur(0px)" },
    exit: (d: 1 | -1) =>
      reduced ? { opacity: 0 } : { opacity: 0, x: d * -24, filter: "blur(4px)" },
  };

  return (
    <div
      className={cn(
        "grid w-full max-w-2xl gap-4 rounded-xl bg-card p-4 shadow-border sm:grid-cols-[1fr_16rem] sm:p-5",
        className,
      )}
      {...props}
    >
      {/* Left: stepper + panels */}
      <div className="min-w-0">
        {/* Progress header */}
        <ol className="mb-5 flex items-center gap-2">
          {STEPS.map((label, i) => {
            const done = i < step || placed;
            const active = i === step && !placed;
            return (
              <li key={label} className="flex flex-1 items-center gap-2">
                <span
                  className={cn(
                    "flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-medium tabular-nums transition-colors duration-200",
                    done
                      ? "bg-success text-success-foreground"
                      : active
                        ? "bg-primary text-primary-foreground"
                        : "bg-secondary text-muted-foreground",
                  )}
                >
                  {done ? <Check className="size-3.5" aria-hidden /> : i + 1}
                </span>
                <span
                  className={cn(
                    "hidden text-xs font-medium sm:block",
                    active ? "text-foreground" : "text-muted-foreground",
                  )}
                >
                  {label}
                </span>
                {i < STEPS.length - 1 && (
                  <span
                    aria-hidden
                    className="h-px flex-1 rounded-full bg-border"
                  />
                )}
              </li>
            );
          })}
        </ol>

        <div className="relative overflow-hidden">
          <AnimatePresence mode="popLayout" custom={dir} initial={false}>
            <motion.div
              key={placed ? "placed" : step}
              custom={dir}
              variants={variants}
              initial="enter"
              animate="center"
              exit="exit"
              transition={{ type: "spring", duration: 0.35, bounce: 0 }}
            >
              {placed ? (
                <Placed />
              ) : (
                <StepPanel
                  step={STEPS[step]}
                  shippingMethods={shippingMethods}
                  shipId={shipId}
                  onShip={setShipId}
                />
              )}
            </motion.div>
          </AnimatePresence>
        </div>

        {!placed && (
          <div className="mt-5 flex items-center gap-2">
            {step > 0 && (
              <Button
                variant="ghost"
                size="sm"
                onClick={() => go(step - 1)}
              >
                <ChevronLeft aria-hidden />
                Back
              </Button>
            )}
            <Button
              className="ml-auto"
              size="sm"
              onClick={() => {
                if (step === STEPS.length - 1) {
                  setPlaced(true);
                  onComplete?.();
                } else {
                  go(step + 1);
                }
              }}
            >
              {step === STEPS.length - 1 ? (
                <>
                  <Lock aria-hidden />
                  Place order
                </>
              ) : (
                "Continue"
              )}
            </Button>
          </div>
        )}
      </div>

      {/* Right: order summary */}
      <aside className="rounded-lg bg-secondary/50 p-4 sm:bg-transparent sm:p-0">
        <h3 className="mb-3 text-sm font-medium text-card-foreground">
          Order summary
        </h3>
        <ul className="flex flex-col gap-2.5">
          {items.map((line, i) => (
            <li key={i} className="flex items-center gap-2.5">
              <span
                aria-hidden
                className="relative size-9 shrink-0 rounded-md"
                style={{ background: placeholder(line.hue ?? i * 47) }}
              >
                <span className="absolute -right-1.5 -top-1.5 flex min-w-4 items-center justify-center rounded-full bg-foreground px-1 text-[10px] font-semibold leading-4 text-background tabular-nums">
                  {line.qty}
                </span>
              </span>
              <span className="min-w-0 flex-1 truncate text-xs text-card-foreground">
                {line.name}
              </span>
              <span className="text-xs tabular-nums text-card-foreground">
                {format.format(line.price * line.qty)}
              </span>
            </li>
          ))}
        </ul>
        <dl className="mt-3 space-y-1.5 border-t border-border pt-3 text-xs">
          <Row label="Subtotal" value={format.format(subtotal)} />
          <Row
            label="Shipping"
            value={shipCost === 0 ? "Free" : format.format(shipCost)}
          />
          <Row label="Tax" value={format.format(tax)} />
        </dl>
        <div className="mt-2 flex items-baseline justify-between border-t border-border pt-2">
          <span className="text-sm font-medium text-card-foreground">
            Total
          </span>
          <span className="text-base font-semibold tabular-nums text-card-foreground">
            {format.format(total)}
          </span>
        </div>
      </aside>
    </div>
  );
}

function Row({ label, value }: { label: string; value: string }) {
  return (
    <div className="flex justify-between">
      <dt className="text-muted-foreground">{label}</dt>
      <dd className="tabular-nums text-card-foreground">{value}</dd>
    </div>
  );
}

function StepPanel({
  step,
  shippingMethods,
  shipId,
  onShip,
}: {
  step: Step;
  shippingMethods: ShippingMethod[];
  shipId?: string;
  onShip: (id: string) => void;
}) {
  if (step === "Contact") {
    return (
      <div className="flex flex-col gap-3">
        <Field label="Email" placeholder="you@example.com" type="email" />
        <div className="grid grid-cols-2 gap-3">
          <Field label="First name" placeholder="Jordan" />
          <Field label="Last name" placeholder="Reyes" />
        </div>
        <Field label="Phone" placeholder="(555) 010-2288" type="tel" />
      </div>
    );
  }
  if (step === "Shipping") {
    return (
      <div className="flex flex-col gap-4">
        <div className="grid gap-3">
          <Field label="Address" placeholder="418 Grand St, Apt 5" />
          <div className="grid grid-cols-3 gap-3">
            <Field label="City" placeholder="Brooklyn" />
            <Field label="State" placeholder="NY" />
            <Field label="ZIP" placeholder="11211" />
          </div>
        </div>
        <div>
          <p className="mb-2 text-xs font-medium text-muted-foreground">
            Delivery method
          </p>
          <ShippingSelector
            methods={shippingMethods}
            value={shipId}
            onValueChange={onShip}
            className="max-w-none"
          />
        </div>
      </div>
    );
  }
  if (step === "Payment") {
    return (
      <div className="flex flex-col gap-3">
        <Field label="Card number" placeholder="4242 4242 4242 4242" />
        <div className="grid grid-cols-2 gap-3">
          <Field label="Expiry" placeholder="08 / 28" />
          <Field label="CVC" placeholder="123" />
        </div>
        <Field label="Name on card" placeholder="Jordan Reyes" />
        <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
          <Lock className="size-3.5" aria-hidden />
          Encrypted and processed securely.
        </p>
      </div>
    );
  }
  // Review
  return (
    <div className="flex flex-col gap-3 text-sm">
      <ReviewRow label="Contact" value="jordan.reyes@example.com" />
      <ReviewRow
        label="Ship to"
        value="418 Grand St, Apt 5 · Brooklyn, NY 11211"
      />
      <ReviewRow
        label="Method"
        value={
          shippingMethods.find((m) => m.id === shipId)?.label ?? "Standard"
        }
      />
      <ReviewRow label="Payment" value="Visa ending 4242" />
    </div>
  );
}

function ReviewRow({ label, value }: { label: string; value: string }) {
  return (
    <div className="flex items-start justify-between gap-4 rounded-lg bg-secondary/40 px-3 py-2">
      <span className="text-xs text-muted-foreground">{label}</span>
      <span className="text-right text-xs text-card-foreground">{value}</span>
    </div>
  );
}

function Field({
  label,
  ...props
}: { label: string } & React.ComponentProps<"input">) {
  const id = React.useId();
  return (
    <label htmlFor={id} className="block">
      <span className="mb-1.5 block text-xs font-medium text-muted-foreground">
        {label}
      </span>
      <input
        id={id}
        className="h-9 w-full rounded-lg border border-input bg-transparent px-3 text-sm text-foreground outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25"
        {...props}
      />
    </label>
  );
}

function Placed() {
  return (
    <div className="flex flex-col items-center gap-3 py-6 text-center">
      <motion.span
        initial={{ scale: 0.9, opacity: 0 }}
        animate={{ scale: 1, opacity: 1 }}
        transition={{ type: "spring", duration: 0.4, bounce: 0.2 }}
        className="flex size-12 items-center justify-center rounded-full bg-success text-success-foreground"
      >
        <Check className="size-6" aria-hidden />
      </motion.span>
      <div>
        <p className="text-sm font-medium text-card-foreground">
          Order placed
        </p>
        <p className="text-xs text-muted-foreground tabular-nums">
          Confirmation #AE-10493 sent to your email
        </p>
      </div>
    </div>
  );
}