A disclosure where line items count up from zero on a stagger and the total counts up to their exact arithmetic sum, so you watch the charge being built.
npx shadcn@latest add @paragon/fee-breakdownAlso installs: number-ticker
"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
export interface FeeLine {
label: string;
/** Amount in major units. Negative renders as a discount/credit. */
amount: number;
/** Optional muted sub-note under the label. */
note?: string;
}
export interface FeeBreakdownProps extends React.ComponentProps<"div"> {
lines: FeeLine[];
/** Label for the summed total. */
totalLabel?: string;
/** ISO currency code. */
currency?: string;
/** Intl locale. */
locale?: string;
/** Start expanded. */
defaultOpen?: boolean;
}
/**
* A disclosure that shows what makes up a charge. When it scrolls into view (or
* opens) each line counts up from zero on a staggered spring and the total
* counts up to the exact sum of the lines — so you watch the number being
* built, not just presented. The total is always the arithmetic sum of the
* lines, computed here, so it can't drift from the parts. Reduced motion shows
* final figures immediately.
*/
export function FeeBreakdown({
lines,
totalLabel = "Total",
currency = "USD",
locale = "en-US",
defaultOpen = true,
className,
...props
}: FeeBreakdownProps) {
const [open, setOpen] = React.useState(defaultOpen);
const reduced = useReducedMotion();
const total = React.useMemo(
() => lines.reduce((sum, l) => sum + l.amount, 0),
[lines],
);
const formatOptions: Intl.NumberFormatOptions = {
style: "currency",
currency,
};
// Total settles after the last line has had time to count, reinforcing "sum".
const totalDelay = reduced ? 0 : Math.min(lines.length * 0.06 + 0.15, 0.6);
return (
<div
className={cn(
"w-full max-w-sm rounded-xl bg-card shadow-border",
className,
)}
{...props}
>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex w-full items-center justify-between gap-3 rounded-xl px-4 py-3 text-left outline-none transition-colors duration-(--duration-fast) hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<span className="text-sm font-medium">{totalLabel}</span>
<span className="flex items-center gap-2">
<span className="text-sm font-semibold tabular-nums">
<NumberTicker
value={total}
delay={totalDelay}
locale={locale}
formatOptions={formatOptions}
/>
</span>
<ChevronDown
aria-hidden
className={cn(
"size-4 text-muted-foreground transition-transform duration-200 ease-out",
open && "rotate-180",
)}
/>
</span>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<ul className="border-t px-4 py-1">
{lines.map((line, i) => (
<li
key={line.label}
className="flex items-baseline justify-between gap-3 border-b py-2.5 last:border-b-0"
>
<span className="min-w-0">
<span className="block text-[13px]">{line.label}</span>
{line.note && (
<span className="block text-[12px] text-muted-foreground">
{line.note}
</span>
)}
</span>
<span
className={cn(
"shrink-0 text-[13px] tabular-nums",
line.amount < 0 ? "text-success" : "text-foreground",
)}
>
{/* NumberTicker counts up once scrolled into view; the
stagger reads left-to-right like a receipt printing. */}
<NumberTicker
value={line.amount}
delay={reduced ? 0 : i * 0.06}
locale={locale}
formatOptions={formatOptions}
/>
</span>
</li>
))}
</ul>
</div>
</div>
</div>
);
}