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