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…
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.
- While typing, only sanitize — never reformat. Keep digits, at most one
decimal mark (the locale's own, read from Intl), and a leading "-" when
allowNegative; drop everything else in place, so pasting "$1,234.56" or
"1 234,56" lands as a clean editable number. 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.