Fintech & Payments

Multi-Currency Wallet

A wallet with per-currency balance rows, a ticking combined total in the base currency, and a slide-open convert panel that previews the cross-rate live.

Install

npx shadcn@latest add @paragon/multi-currency-wallet

Also installs: number-ticker

multi-currency-wallet.tsx

"use client";

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

export interface WalletBalance {
  code: string;
  symbol: string;
  flag: string;
  balance: number;
  /** Value of 1 unit in the base currency. */
  rateToBase: number;
}

export interface MultiCurrencyWalletProps extends React.ComponentProps<"div"> {
  balances: WalletBalance[];
  /** Currency code the total is expressed in. */
  baseCode?: string;
  baseSymbol?: string;
}

/**
 * A multi-currency wallet: per-currency balance rows with flag and code, a
 * combined total in the base currency (ticking as it recomputes), and a
 * convert panel that slides open to move funds between two currencies. The
 * converted amount previews live from the cross-rate; confirming updates both
 * balances in place and rolls the total. Convert panel opens via
 * grid-template-rows so no height jank; reduced motion cross-fades.
 */
export function MultiCurrencyWallet({
  balances: initial,
  baseCode = "USD",
  baseSymbol = "$",
  className,
  ...props
}: MultiCurrencyWalletProps) {
  const reduced = useReducedMotion();
  const [balances, setBalances] = React.useState(initial);
  const [open, setOpen] = React.useState(false);
  const [fromCode, setFromCode] = React.useState(initial[0]?.code ?? "");
  const [toCode, setToCode] = React.useState(initial[1]?.code ?? "");
  const [amount, setAmount] = React.useState("");

  const from = balances.find((b) => b.code === fromCode);
  const to = balances.find((b) => b.code === toCode);
  const amountNum = Number(amount) || 0;

  const total = balances.reduce((s, b) => s + b.balance * b.rateToBase, 0);
  const converted =
    from && to ? (amountNum * from.rateToBase) / to.rateToBase : 0;
  const canConvert =
    from && to && fromCode !== toCode && amountNum > 0 && amountNum <= from.balance;

  function convert() {
    if (!canConvert || !from || !to) return;
    setBalances((bs) =>
      bs.map((b) => {
        if (b.code === fromCode) return { ...b, balance: b.balance - amountNum };
        if (b.code === toCode) return { ...b, balance: b.balance + converted };
        return b;
      }),
    );
    setAmount("");
    setOpen(false);
  }

  const fmt = (v: number, symbol: string) =>
    `${symbol}${v.toLocaleString("en-US", {
      minimumFractionDigits: 2,
      maximumFractionDigits: 2,
    })}`;

  return (
    <div
      className={cn(
        "w-full max-w-sm overflow-hidden rounded-xl bg-card p-5 shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-start justify-between">
        <div>
          <p className="text-[13px] font-medium text-muted-foreground">
            Total balance
          </p>
          <p className="text-2xl font-semibold tabular-nums">
            <NumberTicker
              value={total}
              static={!!reduced}
              duration={0.4}
              formatOptions={{
                style: "currency",
                currency: baseCode,
              }}
            />
          </p>
        </div>
        <button
          type="button"
          onClick={() => setOpen((o) => !o)}
          aria-expanded={open}
          className={cn(
            "pressable inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-[13px] font-medium transition-colors duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
            open
              ? "bg-primary text-primary-foreground"
              : "shadow-border hover:shadow-border-hover",
          )}
        >
          <ArrowLeftRight className="size-3.5" aria-hidden />
          Convert
        </button>
      </div>

      <ul className="mt-4 flex flex-col gap-1">
        {balances.map((b) => (
          <li
            key={b.code}
            className="flex items-center gap-3 rounded-lg px-1 py-2"
          >
            <span
              className="flex size-8 items-center justify-center rounded-full bg-muted text-base leading-none"
              aria-hidden
            >
              {b.flag}
            </span>
            <div className="min-w-0 flex-1">
              <p className="text-sm font-medium">{b.code}</p>
              <p className="text-[12px] text-muted-foreground tabular-nums">
                1 {b.code} = {baseSymbol}
                {b.rateToBase.toFixed(2)}
              </p>
            </div>
            <span className="text-sm font-medium tabular-nums">
              {fmt(b.balance, b.symbol)}
            </span>
          </li>
        ))}
      </ul>

      <div
        className={cn(
          "grid transition-[grid-template-rows] duration-250 ease-out motion-reduce:transition-none",
          open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
        )}
      >
        <div className="overflow-hidden">
          <AnimatePresence initial={false}>
            {open && (
              <motion.div
                initial={reduced ? { opacity: 0 } : { opacity: 0, y: -6 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
                className="mt-3 rounded-lg border p-3"
              >
                <div className="flex items-center gap-2">
                  <select
                    value={fromCode}
                    onChange={(e) => setFromCode(e.target.value)}
                    aria-label="Convert from"
                    className="h-8 rounded-md border bg-transparent px-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25"
                  >
                    {balances.map((b) => (
                      <option key={b.code} value={b.code}>
                        {b.code}
                      </option>
                    ))}
                  </select>
                  <ArrowRight
                    className="size-4 shrink-0 text-muted-foreground"
                    aria-hidden
                  />
                  <select
                    value={toCode}
                    onChange={(e) => setToCode(e.target.value)}
                    aria-label="Convert to"
                    className="h-8 rounded-md border bg-transparent px-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25"
                  >
                    {balances.map((b) => (
                      <option key={b.code} value={b.code}>
                        {b.code}
                      </option>
                    ))}
                  </select>
                </div>

                <div className="mt-2 flex items-center gap-2">
                  <input
                    inputMode="decimal"
                    value={amount}
                    onChange={(e) =>
                      setAmount(e.target.value.replace(/[^\d.]/g, ""))
                    }
                    placeholder={`0.00 ${fromCode}`}
                    aria-label="Amount to convert"
                    className="h-9 min-w-0 flex-1 rounded-md border bg-transparent px-3 text-sm tabular-nums outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25"
                  />
                </div>

                <div className="mt-2 flex items-center justify-between text-[13px]">
                  <span className="text-muted-foreground">You receive</span>
                  <span className="font-medium tabular-nums">
                    {to ? fmt(converted, to.symbol) : "—"} {toCode}
                  </span>
                </div>
                {from && amountNum > from.balance && (
                  <p className="mt-1 text-[12px] text-destructive">
                    Exceeds available {fromCode} balance.
                  </p>
                )}

                <button
                  type="button"
                  onClick={convert}
                  disabled={!canConvert}
                  className="pressable mt-3 flex h-9 w-full items-center justify-center gap-1.5 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 disabled:pointer-events-none disabled:opacity-50"
                >
                  <Check className="size-4" aria-hidden />
                  Convert
                </button>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      </div>
    </div>
  );
}