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
"use client"

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

export interface CurrencyInputProps
  extends Omit<
    React.InputHTMLAttributes<HTMLInputElement>,
    "value" | "onChange" | "type" | "min" | "max" | "step" | "inputMode"
  > {
  /** Controlled amount. `null` is a legal value — it means "the field is empty". */
  value: number | null
  onValueChange: (value: number | null) => void
  /** ISO 4217 code; the symbol is read from Intl, never hardcoded. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/currency-input.json

Prompt

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.56 works and typing 12. stays 12. until you finish the cents.
  • null is a first-class value — an empty field parses to null, never 0, so "no amount entered yet" survives all the way to your validation layer.
  • Clamp at the commit boundary — min/max apply on blur only; clamping per keystroke would make typing 5 on the way to 50 impossible when the floor is 10.
  • Symbol from Intl, not from a table — formatToParts supplies both the glyph and whether it leads or trails, so en-US/USD renders $1.00 and de-DE/EUR renders 1,00 € with no per-currency branching.
  • Tabular alignment — right alignment plus tabular-nums keeps a column of amounts scannable, which is the main reason a money field differs from a generic number input.

On This Page