Fintech & Payments

Checkout Summary

An order summary with line items, a collapsible promo field, tax and shipping, and a pay button whose total rolls to its new value whenever the math changes.

Install

npx shadcn@latest add @paragon/checkout-summary

Also installs: number-ticker

checkout-summary.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Tag, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";

export interface CheckoutLineItem {
  id: string;
  name: string;
  detail?: string;
  /** Unit price in the smallest workable unit (dollars, as a number). */
  price: number;
  quantity?: number;
}

export interface CheckoutPromo {
  code: string;
  /** Fraction off, 0–1, applied to the subtotal. */
  rate: number;
}

export interface CheckoutSummaryProps extends React.ComponentProps<"div"> {
  items: CheckoutLineItem[];
  /** Sales tax rate applied after any discount, e.g. 0.0875. */
  taxRate?: number;
  /** Flat shipping in dollars. */
  shipping?: number;
  currency?: string;
  /** Available codes the user can apply; enables the promo field when present. */
  promos?: CheckoutPromo[];
  payLabel?: string;
  onPay?: (total: number) => void;
}

/**
 * An order summary: line items, a collapsible promo field, tax and shipping,
 * and a pay button whose amount rolls to its new value with a number ticker
 * whenever the math changes (quantity, applied discount). Every figure is
 * tabular so the column never jitters. The total is the exact arithmetic sum
 * of what's shown — nothing is hidden.
 */
export function CheckoutSummary({
  items,
  taxRate = 0,
  shipping = 0,
  currency = "USD",
  promos,
  payLabel = "Pay now",
  onPay,
  className,
  ...props
}: CheckoutSummaryProps) {
  const reduced = useReducedMotion();
  const [applied, setApplied] = React.useState<CheckoutPromo | null>(null);
  const [code, setCode] = React.useState("");
  const [codeError, setCodeError] = React.useState(false);

  const money = React.useMemo(
    () =>
      new Intl.NumberFormat("en-US", {
        style: "currency",
        currency,
      }),
    [currency],
  );

  const subtotal = items.reduce(
    (sum, it) => sum + it.price * (it.quantity ?? 1),
    0,
  );
  const discount = applied ? subtotal * applied.rate : 0;
  const taxed = (subtotal - discount) * taxRate;
  const total = subtotal - discount + taxed + shipping;

  function tryApply() {
    const match = promos?.find(
      (p) => p.code.toLowerCase() === code.trim().toLowerCase(),
    );
    if (match) {
      setApplied(match);
      setCode("");
      setCodeError(false);
    } else {
      setCodeError(true);
    }
  }

  const Row = ({
    label,
    value,
    muted,
    negative,
  }: {
    label: React.ReactNode;
    value: number;
    muted?: boolean;
    negative?: boolean;
  }) => (
    <div className="flex items-center justify-between text-sm">
      <span className={muted ? "text-muted-foreground" : undefined}>
        {label}
      </span>
      <span
        className={cn(
          "tabular-nums",
          muted && "text-muted-foreground",
          negative && "text-success",
        )}
      >
        {negative ? "−" : ""}
        {money.format(Math.abs(value))}
      </span>
    </div>
  );

  return (
    <div
      className={cn(
        "flex w-full max-w-sm flex-col rounded-xl bg-card p-5 shadow-border",
        className,
      )}
      {...props}
    >
      <h3 className="text-sm font-medium">Order summary</h3>

      <ul className="mt-4 flex flex-col gap-3">
        {items.map((it) => (
          <li key={it.id} className="flex items-start justify-between gap-3">
            <div className="min-w-0">
              <p className="truncate text-sm font-medium">{it.name}</p>
              {it.detail && (
                <p className="truncate text-[13px] text-muted-foreground">
                  {it.detail}
                  {it.quantity && it.quantity > 1 ? ` · ×${it.quantity}` : ""}
                </p>
              )}
            </div>
            <span className="shrink-0 text-sm tabular-nums">
              {money.format(it.price * (it.quantity ?? 1))}
            </span>
          </li>
        ))}
      </ul>

      {promos && promos.length > 0 && (
        <div className="mt-4">
          <AnimatePresence mode="wait" initial={false}>
            {applied ? (
              <motion.div
                key="applied"
                initial={reduced ? false : { opacity: 0, y: 6 }}
                animate={{ opacity: 1, y: 0 }}
                exit={reduced ? { opacity: 0 } : { opacity: 0, y: -6 }}
                transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
                className="flex items-center justify-between rounded-lg bg-success/10 px-3 py-2"
              >
                <span className="inline-flex items-center gap-1.5 text-[13px] font-medium text-success">
                  <Tag className="size-3.5" aria-hidden />
                  {applied.code} · {Math.round(applied.rate * 100)}% off
                </span>
                <button
                  type="button"
                  aria-label={`Remove promo code ${applied.code}`}
                  onClick={() => setApplied(null)}
                  className="pressable flex size-6 items-center justify-center rounded-md text-success/80 transition-colors duration-150 hover:text-success"
                >
                  <X className="size-3.5" aria-hidden />
                </button>
              </motion.div>
            ) : (
              <motion.div
                key="field"
                initial={reduced ? false : { opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.15 }}
                className="flex gap-2"
              >
                <input
                  value={code}
                  onChange={(e) => {
                    setCode(e.target.value);
                    setCodeError(false);
                  }}
                  onKeyDown={(e) => e.key === "Enter" && tryApply()}
                  placeholder="Promo code"
                  aria-label="Promo code"
                  aria-invalid={codeError}
                  className={cn(
                    "h-9 min-w-0 flex-1 rounded-lg border bg-transparent px-3 text-sm outline-none transition-[border-color,box-shadow] duration-150 ease-out placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
                    codeError && "border-destructive",
                  )}
                />
                <button
                  type="button"
                  onClick={tryApply}
                  className="pressable h-9 shrink-0 rounded-lg px-3 text-sm font-medium shadow-border transition-[box-shadow] duration-150 ease-out hover:shadow-border-hover"
                >
                  Apply
                </button>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      )}

      <div className="mt-4 flex flex-col gap-2 border-t pt-4">
        <Row label="Subtotal" value={subtotal} muted />
        {applied && (
          <Row label={`Discount (${applied.code})`} value={discount} negative />
        )}
        {taxRate > 0 && (
          <Row
            label={`Tax (${(taxRate * 100).toFixed(2).replace(/\.00$/, "")}%)`}
            value={taxed}
            muted
          />
        )}
        {shipping > 0 && <Row label="Shipping" value={shipping} muted />}
      </div>

      <div className="mt-4 flex items-center justify-between border-t pt-4">
        <span className="text-sm font-medium">Total</span>
        <span className="text-lg font-semibold">
          <NumberTicker
            value={total}
            static={!!reduced}
            duration={0.4}
            formatOptions={{ style: "currency", currency }}
          />
        </span>
      </div>

      <button
        type="button"
        onClick={() => onPay?.(total)}
        className="pressable mt-4 flex h-10 items-center justify-center rounded-lg bg-primary text-sm font-medium text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
      >
        {payLabel} · {money.format(total)}
      </button>
    </div>
  );
}