Fintech & Payments

Fee Breakdown

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.

Install

npx shadcn@latest add @paragon/fee-breakdown

Also installs: number-ticker

fee-breakdown.tsx

"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>
  );
}