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…

"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.
- 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.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 boundarymin/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 tableformatToParts 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