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.
npx shadcn@latest add @paragon/money-input"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>
);
}