A two-field converter where typing the source amount rolls the converted field's digits in step, with a swap control and a live rate line.
npx shadcn@latest add @paragon/currency-converterAlso installs: digit-roll
"use client";
import * as React from "react";
import { ArrowRightLeft } from "lucide-react";
import { cn } from "@/lib/utils";
import { DigitRoll } from "@/registry/paragon/ui/digit-roll";
export interface CurrencyConverterProps extends React.ComponentProps<"div"> {
/** Source currency code. */
from?: string;
/** Target currency code. */
to?: string;
/** Units of `to` per 1 unit of `from`. */
rate?: number;
/** Initial amount in the source field. */
defaultAmount?: number;
/** Intl locale. */
locale?: string;
}
/**
* A two-field converter. You type into the source amount; the converted field
* isn't an input but a DigitRoll, so its digits roll to the new value in step
* as you type — the conversion reads as a live mechanical response rather than
* a reprint. The rate line sits between them with a swap control that flips
* direction. Reduced motion swaps the converted figure in place.
*/
export function CurrencyConverter({
from = "USD",
to = "EUR",
rate = 0.92,
defaultAmount = 1000,
locale = "en-US",
className,
...props
}: CurrencyConverterProps) {
const [amount, setAmount] = React.useState(defaultAmount);
const [flipped, setFlipped] = React.useState(false);
const baseId = React.useId();
const srcCode = flipped ? to : from;
const dstCode = flipped ? from : to;
const effectiveRate = flipped ? 1 / rate : rate;
const converted = Math.round(amount * effectiveRate * 100) / 100;
const srcSymbol = React.useMemo(
() => symbolFor(srcCode, locale),
[srcCode, locale],
);
const dstSymbol = React.useMemo(
() => symbolFor(dstCode, locale),
[dstCode, locale],
);
const rateFmt = React.useMemo(
() =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 4,
}),
[locale],
);
function handleAmount(e: React.ChangeEvent<HTMLInputElement>) {
const n = Number(e.target.value.replace(/[^\d.]/g, ""));
setAmount(Number.isNaN(n) ? 0 : n);
}
return (
<div
className={cn(
"w-full max-w-sm rounded-xl bg-card p-4 shadow-border",
className,
)}
{...props}
>
{/* Source field */}
<label
htmlFor={`${baseId}-amount`}
className="mb-1.5 block text-[13px] font-medium text-muted-foreground"
>
{srcCode}
</label>
<div className="flex h-11 items-center rounded-lg border border-input bg-transparent px-3 transition-[border-color,box-shadow] duration-150 ease-out focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25">
<span className="mr-1.5 shrink-0 select-none text-muted-foreground">
{srcSymbol}
</span>
<input
id={`${baseId}-amount`}
inputMode="decimal"
autoComplete="off"
value={amount.toString()}
onChange={handleAmount}
aria-label={`Amount in ${srcCode}`}
className="w-full min-w-0 flex-1 bg-transparent text-lg font-medium tabular-nums outline-none"
/>
</div>
{/* Rate line + swap */}
<div className="my-2 flex items-center gap-2 px-0.5">
<button
type="button"
onClick={() => setFlipped((f) => !f)}
aria-label="Swap direction"
className="pressable flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground shadow-border transition-colors duration-150 ease-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<ArrowRightLeft className="size-3.5" aria-hidden />
</button>
<span className="text-[12px] tabular-nums text-muted-foreground">
1 {srcCode} = {rateFmt.format(effectiveRate)} {dstCode}
</span>
</div>
{/* Converted field (rolling) */}
<span className="mb-1.5 block text-[13px] font-medium text-muted-foreground">
{dstCode}
</span>
<div className="flex h-11 items-center rounded-lg bg-muted/50 px-3">
<span className="mr-1.5 shrink-0 select-none text-muted-foreground">
{dstSymbol}
</span>
<span className="text-lg font-medium tabular-nums">
<DigitRoll
value={converted}
locale={locale}
formatOptions={{
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}}
/>
</span>
</div>
</div>
);
}
function symbolFor(code: string, locale: string) {
try {
const parts = new Intl.NumberFormat(locale, {
style: "currency",
currency: code,
currencyDisplay: "narrowSymbol",
}).formatToParts(0);
return parts.find((p) => p.type === "currency")?.value ?? code;
} catch {
return code;
}
}