A multi-step checkout (contact, shipping, payment, review) with a progress header, direction-aware panel transitions, and a live order summary.
npx shadcn@latest add @paragon/checkout-flowAlso installs: button, shipping-selector
"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>
);
}