Fintech & Payments

Currency Converter

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.

Install

npx shadcn@latest add @paragon/currency-converter

Also installs: digit-roll

currency-converter.tsx

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