Inputs
Currency Input
A money field that stays raw while you type and normalizes with Intl.NumberFormat on blur — grouping, fixed decimals, clamping, and a number | null value.
Preview in your theme
Loading preview…
- Registry dependencies
- utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/currency-input.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CurrencyInput" component (no runtime
deps beyond React — all formatting comes from the platform Intl API).
Contract
- Export a forwardRef component whose ref points at the inner <input>; props
extend InputHTMLAttributes minus value/onChange/type/min/max/step/inputMode.
- Controlled amount: value (number | null) + onValueChange(next: number | null).
null is a legal value meaning "empty" — an empty field must never become 0.
- Options: currency (ISO 4217, default "USD"), locale (default "en-US"),
precision (default 2), allowNegative (default false), min / max (default
±Infinity), invalid, description, disabled, className.
- description renders under the field and is linked through aria-describedby,
merged with any aria-describedby the consumer passes.
Behavior
- Two display modes for one value. Blurred: Intl.NumberFormat(locale, {min/max
FractionDigits: precision}) — grouping separators and fixed decimals.
Focused: the bare ungrouped number, so the caret can never be thrown around
by a separator appearing mid-word. Focus swaps in the editable text, blur
swaps back. The editable text uses numberingSystem "latn" with bidi marks
stripped and U+2212 turned into "-", so it parses back in every locale
(sv-SE writes "−42,5", ar-EG writes Arabic-Indic digits).
- While typing, only sanitize — never reformat. Keep digits, at most one
decimal mark (the locale's own in Latin digits, read from Intl), and a
leading "-" or U+2212 when allowNegative; drop everything else in place, so
pasting "$1,234.56" or "1 234,56" lands as a clean editable number. The
locale's native digits and native decimal mark (read by formatting
9876543210.1 with Intl) map to their Latin forms, so typing or pasting
"١٢٣٤٫٥" works. Fraction digits are truncated at precision as they are
typed.
- Every sanitized keystroke parses and calls onValueChange, so the parent's
number stays live; "" and a lone "-" both parse to null.
- On blur: round to precision, clamp into [min, max], call onValueChange only
if the committed number differs, then drop the draft so the formatted
display takes over. Clamping on blur (not per keystroke) is what lets a user
type 5 on the way to 50 when min is 10.
- The currency symbol and its side (prefix for "$1.00", suffix for "1,00 €")
come from Intl.NumberFormat(...).formatToParts — never a hardcoded map. It
renders as its own aria-hidden span, outside the input's text.
- No animation, so there is nothing to gate on prefers-reduced-motion.
Rendering & styling
- Field mirrors the shadcn input: h-9 rounded-md border border-input
bg-transparent, focus-within:border-ring + focus-within:ring-ring/50;
invalid swaps to border-destructive + ring-destructive/30; disabled dims
with opacity-50 and cursor-not-allowed.
- Symbol span: text-muted-foreground, select-none, shrink-0. Input:
text-right + tabular-nums so a column of amounts lines up,
inputMode="decimal" for a numeric mobile keypad, aria-invalid when invalid.
- description: text-xs, text-muted-foreground normally, text-destructive when
invalid. Semantic tokens only — dark mode is free.
- Merge the consumer className onto the outer wrapper via cn().
Customization levers
- Symbol slot: replace the symbol span with the ISO code, a flag, or a
currency <select> to build a multi-currency field — parsing and formatting
stay untouched.
- precision: 0 for whole-unit currencies (JPY, KRW), 2 for most, 3 for KWD;
pair it with min/max expressed in the same unit.
- Storage unit: if your API stores minor units (cents), multiply on commit and
divide when passing value in — do it in the parent and keep the component in
major units.
- Commit timing: for a form that only reads on submit, drop the per-keystroke
onValueChange and emit on blur only; the draft state already supports it.
- Density and alignment: h-9 / px-3 / text-sm are the sizing knobs; drop
text-right for left-aligned money when the field sits inline in a sentence.
- Validation: keep invalid + description driven by your form library
(zod z.number().min(...)); the component only clamps, it never blocks input.Concepts
- Format on blur, raw on focus — normalization only happens at a moment when the caret is not in the field, so a thousands separator can never appear mid-word and shove the cursor somewhere the user did not ask for.
- Sanitize, don't reformat — keystrokes are filtered (illegal characters dropped in place) instead of rewritten, which is why pasting
$1,234.56works and typing12.stays12.until you finish the cents. - null is a first-class value — an empty field parses to
null, never0, so "no amount entered yet" survives all the way to your validation layer. - Clamp at the commit boundary —
min/maxapply on blur only; clamping per keystroke would make typing5on the way to50impossible when the floor is10. - Symbol from Intl, not from a table —
formatToPartssupplies both the glyph and whether it leads or trails, soen-US/USDrenders$1.00andde-DE/EURrenders1,00 €with no per-currency branching. - Tabular alignment — right alignment plus
tabular-numskeeps a column of amounts scannable, which is the main reason a money field differs from a generic number input.