Fintech & Payments

Money Input

A currency input that groups digits live as you type, right-aligns the figure, and snaps to fixed fraction digits on blur with a one-shot settle.

Install

npx shadcn@latest add @paragon/money-input

money-input.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface MoneyInputProps
  extends Omit<React.ComponentProps<"input">, "value" | "defaultValue" | "onChange"> {
  /** Controlled value in major units (e.g. 1250.5 for $1,250.50). Null = empty. */
  value?: number | null;
  /** Uncontrolled initial value in major units. */
  defaultValue?: number | null;
  /** Fires with the parsed major-unit number, or null when cleared. */
  onValueChange?: (value: number | null) => void;
  /** ISO 4217 currency code driving the symbol and fraction digits. */
  currency?: string;
  /** Intl locale for grouping and the symbol. */
  locale?: string;
  /** Visible label, wired via htmlFor. */
  label?: string;
  /** Helper text below the field. */
  hint?: string;
}

function fractionDigitsFor(currency: string, locale: string) {
  const parts = new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
  }).formatToParts(1);
  const fraction = parts.find((p) => p.type === "fraction");
  return fraction ? fraction.value.length : 0;
}

/**
 * A currency input that groups digits live as you type and right-aligns the
 * figure like a ledger cell. Typing is kept lightweight — only grouping is
 * applied while focused, so the caret never fights the formatter. On blur the
 * value snaps to fixed fraction digits with a one-shot "settle" (a 2px blur
 * clearing to crisp), signalling the amount is now committed. The symbol sits
 * in a muted prefix so it never scrolls with the number.
 */
export function MoneyInput({
  value,
  defaultValue = null,
  onValueChange,
  currency = "USD",
  locale = "en-US",
  label,
  hint,
  className,
  id: idProp,
  disabled,
  placeholder,
  onBlur,
  onFocus,
  "aria-describedby": ariaDescribedBy,
  ...props
}: MoneyInputProps) {
  const autoId = React.useId();
  const id = idProp ?? autoId;
  const hintId = `${id}-hint`;
  const controlled = value !== undefined;

  const fractionDigits = React.useMemo(
    () => fractionDigitsFor(currency, locale),
    [currency, locale],
  );

  const symbol = React.useMemo(() => {
    const parts = new Intl.NumberFormat(locale, {
      style: "currency",
      currency,
      currencyDisplay: "narrowSymbol",
    }).formatToParts(0);
    return parts.find((p) => p.type === "currency")?.value ?? "$";
  }, [currency, locale]);

  const grouping = React.useMemo(() => new Intl.NumberFormat(locale), [locale]);

  const toDisplay = React.useCallback(
    (n: number | null, committed: boolean) => {
      if (n === null || Number.isNaN(n)) return "";
      return new Intl.NumberFormat(locale, {
        minimumFractionDigits: committed ? fractionDigits : 0,
        maximumFractionDigits: fractionDigits,
      }).format(n);
    },
    [locale, fractionDigits],
  );

  const [text, setText] = React.useState(() =>
    toDisplay(controlled ? (value ?? null) : defaultValue, true),
  );
  const [settling, setSettling] = React.useState(false);

  // Keep the display in sync when a controlled value changes externally.
  React.useEffect(() => {
    if (controlled) setText(toDisplay(value ?? null, true));
  }, [controlled, value, toDisplay]);

  function parse(raw: string): number | null {
    const cleaned = raw.replace(/[^\d.,-]/g, "");
    // Strip group separators (anything that's not the last dot) — keep a single
    // decimal point. This is locale-lenient by design for the common cases.
    const normalized = cleaned
      .replace(/,/g, "")
      .replace(/(\..*)\./g, "$1");
    if (normalized === "" || normalized === "-" || normalized === ".") return null;
    const n = Number(normalized);
    return Number.isNaN(n) ? null : n;
  }

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const raw = e.target.value;
    const parsed = parse(raw);
    // Live grouping only on the integer part; leave a trailing decimal / zeros
    // untouched so the caret stays predictable.
    const match = raw.replace(/[^\d.]/g, "").match(/^(\d*)(\.\d*)?$/);
    let next = raw;
    if (match) {
      const intPart = match[1];
      const decPart = match[2] ?? "";
      const grouped = intPart ? grouping.format(Number(intPart)) : "";
      next = grouped + decPart;
    }
    setText(next);
    onValueChange?.(parsed);
  }

  function handleBlur(e: React.FocusEvent<HTMLInputElement>) {
    const parsed = parse(text);
    setText(toDisplay(parsed, true));
    if (parsed !== null) {
      setSettling(true);
    }
    onBlur?.(e);
  }

  return (
    <div className={cn("w-full", className)}>
      <style href="paragon-money-input" precedence="paragon">{`
        @keyframes paragon-money-settle {
          from { filter: blur(2px); opacity: 0.65; }
          to { filter: blur(0); opacity: 1; }
        }
        @media (prefers-reduced-motion: reduce) {
          .paragon-money-settle { animation: none !important; }
        }
      `}</style>

      {label && (
        <label
          htmlFor={id}
          className="mb-1.5 block text-sm font-medium text-foreground"
        >
          {label}
        </label>
      )}

      <div
        className={cn(
          "relative flex h-9 w-full items-center rounded-lg border border-input bg-transparent pl-3 pr-3 text-sm",
          "transition-[border-color,box-shadow] duration-150 ease-out",
          "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25",
          disabled && "cursor-not-allowed opacity-50",
        )}
      >
        <span
          aria-hidden
          className="mr-1 shrink-0 select-none text-muted-foreground tabular-nums"
        >
          {symbol}
        </span>
        <input
          id={id}
          inputMode="decimal"
          autoComplete="off"
          disabled={disabled}
          value={text}
          placeholder={placeholder ?? toDisplay(0, true)}
          aria-describedby={cn(hint ? hintId : undefined, ariaDescribedBy) || undefined}
          onChange={handleChange}
          onFocus={onFocus}
          onBlur={handleBlur}
          onAnimationEnd={(e) => {
            if (e.animationName === "paragon-money-settle") setSettling(false);
          }}
          className={cn(
            "w-full min-w-0 flex-1 bg-transparent text-right tabular-nums outline-none",
            "placeholder:text-muted-foreground",
            settling && "paragon-money-settle",
          )}
          style={
            settling
              ? { animation: "paragon-money-settle 220ms var(--ease-out) both" }
              : undefined
          }
          {...props}
        />
      </div>

      {hint && (
        <p id={hintId} className="mt-1.5 text-[13px] text-muted-foreground">
          {hint}
        </p>
      )}
    </div>
  );
}