A checkout breakdown with rolling totals and a promo-code field that draws a check and slides a discount row in on apply.
npx shadcn@latest add @paragon/order-summaryAlso installs: digit-roll
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { DigitRoll } from "@/registry/paragon/ui/digit-roll";
export interface OrderSummaryProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
subtotal: number;
shipping?: number;
/** Tax rate applied to (subtotal − discount), e.g. 0.0875. */
taxRate?: number;
currency?: string;
locale?: Intl.LocalesArgument;
/** Enable the promo-code field. */
promo?: boolean;
/**
* Resolve a code to a discount amount (in currency units). Return 0/undefined
* to reject the code. Defaults to a demo table.
*/
onApplyPromo?: (code: string) => number | Promise<number>;
static?: boolean;
}
const DEMO_CODES: Record<string, number> = {
WELCOME10: 10,
FREESHIP: 8,
TAKE20: 20,
};
/**
* A checkout summary: subtotal, shipping, tax, and total, each total rolling
* as inputs change. An optional promo field validates on apply — success draws
* a check inside the field and slides a discount row into the breakdown; a bad
* code shakes the field. Rows enter/exit via grid-rows so height stays cheap.
*/
export function OrderSummary({
subtotal,
shipping = 0,
taxRate = 0,
currency = "USD",
locale = "en-US",
promo = true,
onApplyPromo,
static: isStatic = false,
className,
...props
}: OrderSummaryProps) {
const reduced = useReducedMotion() ?? false;
const [code, setCode] = React.useState("");
const [applied, setApplied] = React.useState<{
code: string;
amount: number;
} | null>(null);
const [status, setStatus] = React.useState<"idle" | "loading" | "error">(
"idle",
);
const shake = status === "error";
const format = (n: number) =>
new Intl.NumberFormat(locale, { style: "currency", currency }).format(n);
const discount = applied?.amount ?? 0;
const taxable = Math.max(0, subtotal - discount);
const tax = taxable * taxRate;
const total = taxable + shipping + tax;
const apply = async () => {
const trimmed = code.trim().toUpperCase();
if (!trimmed || status === "loading") return;
setStatus("loading");
try {
const resolver = onApplyPromo ?? ((c: string) => DEMO_CODES[c] ?? 0);
const amount = await resolver(trimmed);
if (amount > 0) {
setApplied({ code: trimmed, amount });
setStatus("idle");
setCode("");
} else {
setStatus("error");
}
} catch {
setStatus("error");
}
};
const Row = ({
label,
value,
muted,
negative,
}: {
label: string;
value: number;
muted?: boolean;
negative?: boolean;
}) => (
<div className="flex items-center justify-between py-1.5 text-sm">
<span className={muted ? "text-muted-foreground" : undefined}>
{label}
</span>
<span className={cn("tabular-nums", negative && "text-success")}>
{negative && "−"}
<DigitRoll
value={negative ? Math.abs(value) : value}
locale={locale}
formatOptions={{ style: "currency", currency }}
static={isStatic}
/>
</span>
</div>
);
return (
<>
<style href="paragon-order-summary" precedence="paragon">{`
@keyframes pg-summary-shake {
10%, 90% { translate: -1px 0; }
30%, 70% { translate: 3px 0; }
50% { translate: -4px 0; }
}
@media (prefers-reduced-motion: reduce) {
.pg-summary-field { animation: none !important; }
}
`}</style>
<div
className={cn(
"flex w-full flex-col rounded-xl bg-card p-5 shadow-border",
className,
)}
{...props}
>
<h3 className="mb-1 text-sm font-semibold">Order summary</h3>
<div className="divide-y divide-border/60">
<div className="py-1">
<Row label="Subtotal" value={subtotal} muted />
<div
className={cn(
"grid transition-[grid-template-rows] duration-250 ease-out",
applied ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
{applied && (
<div className="flex items-center justify-between py-1.5 text-sm">
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
Discount
<span className="rounded bg-success/12 px-1.5 py-0.5 text-xs font-medium text-success">
{applied.code}
</span>
</span>
<span className="text-success tabular-nums">
−{format(applied.amount)}
</span>
</div>
)}
</div>
</div>
<Row label="Shipping" value={shipping} muted />
{taxRate > 0 && <Row label="Tax" value={tax} muted />}
</div>
<div className="flex items-center justify-between py-3 text-base font-semibold">
<span>Total</span>
<span className="tabular-nums">
<DigitRoll
value={total}
locale={locale}
formatOptions={{ style: "currency", currency }}
static={isStatic}
/>
</span>
</div>
</div>
{promo && !applied && (
<div
className={cn(
"pg-summary-field mt-1 flex items-center gap-2",
shake && "[animation:pg-summary-shake_280ms_var(--ease-out)]",
)}
onAnimationEnd={() => shake && setStatus("idle")}
>
<div className="relative flex-1">
<input
value={code}
onChange={(e) => {
setCode(e.target.value);
if (status === "error") setStatus("idle");
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
apply();
}
}}
placeholder="Promo code"
aria-label="Promo code"
aria-invalid={status === "error" || undefined}
className={cn(
"h-9 w-full rounded-lg border bg-transparent px-3 text-sm uppercase outline-none transition-[border-color,box-shadow] duration-150 placeholder:normal-case placeholder:text-muted-foreground focus:ring-2 focus:ring-ring/30",
status === "error"
? "border-destructive focus:border-destructive focus:ring-destructive/25"
: "border-input focus:border-ring",
)}
/>
</div>
<button
type="button"
onClick={apply}
disabled={!code.trim() || status === "loading"}
className={cn(
"inline-flex h-9 items-center justify-center gap-1.5 rounded-lg bg-secondary px-3.5 text-sm font-medium text-secondary-foreground outline-none transition-[background-color,scale] duration-150 hover:bg-secondary/80 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
!isStatic && "active:not-disabled:scale-[0.97]",
)}
>
{status === "loading" ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
Apply
</button>
</div>
)}
{promo && applied && (
<AnimatePresence>
<motion.div
key="applied"
initial={
isStatic || reduced
? false
: { opacity: 0, y: 4, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
className="mt-1 flex items-center gap-2 text-sm text-success"
>
<span className="flex size-4 items-center justify-center rounded-full bg-success text-success-foreground">
<Check className="size-3" strokeWidth={3} aria-hidden />
</span>
Code {applied.code} applied
<button
type="button"
onClick={() => setApplied(null)}
className="ml-auto text-xs text-muted-foreground underline-offset-2 hover:underline"
>
Remove
</button>
</motion.div>
</AnimatePresence>
)}
</div>
</>
);
}