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.
npx shadcn@latest add @paragon/checkout-summaryAlso installs: number-ticker
"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>
);
}