A locale-aware money field that formats on blur while keeping editing and caret behavior simple.
Project budget
Formatted for your locale; edit as a number.
import { CurrencyInput } from "@/components/ballmac/currency-input"
export default function CurrencyInputDemo() {
return (
<div className="w-full max-w-xs rounded-xl border border-border bg-card p-5">
<p className="mb-4 text-sm font-semibold">Project budget</p>
<CurrencyInput
label="Monthly budget"
currency="USD"
defaultValue={4250.5}
min={0}
/>
<p className="text-muted-foreground mt-3 text-xs">
Formatted for your locale; edit as a number.
</p>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/currency-inputAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/currency-input.tsx// Ballmac UI: Currency Input. https://ui.ballmac.com/components/currency-input "use client" import * as React from "react" import { cn } from "@/lib/utils" import { useLocale } from "@/lib/ballmac/i18n" type CurrencyInputProps = Omit< React.ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "type" > & { /** Visible and accessible input label. */ label: string /** ISO 4217 currency code. */ currency?: string /** Fixed locale for formatting and parsing. */ locale?: string /** Controlled numeric amount, or null when empty. */ value?: number | null /** Initial numeric amount. */ defaultValue?: number | null /** Called with the parsed numeric amount while editing. */ onValueChange?: (value: number | null) => void /** Smallest allowed amount. */ min?: number /** Largest allowed amount. */ max?: number } function CurrencyInput({ className, label, currency = "USD", locale, value, defaultValue = null, onValueChange, min, max, onFocus, onBlur, ...props }: CurrencyInputProps) { const defaultLocale = useLocale() locale ??= defaultLocale const [internal, setInternal] = React.useState<number | null>(defaultValue) const [editing, setEditing] = React.useState(false) const [draft, setDraft] = React.useState("") const current = value === undefined ? internal : value const formatter = new Intl.NumberFormat(locale, { style: "currency", currency, }) const decimal = formatter.formatToParts(1.1).find((part) => part.type === "decimal") ?.value ?? "." const group = formatter.formatToParts(1000).find((part) => part.type === "group") ?.value ?? "," const digits = formatter.resolvedOptions().maximumFractionDigits const shown = editing ? draft : current === null ? "" : formatter.format(current) function report(next: number | null) { if (value === undefined) setInternal(next) onValueChange?.(next) } function parse(raw: string) { const compact = raw .replaceAll(group, "") .replaceAll(decimal, ".") .replace(/[^\d.\-]/g, "") if (!compact || compact === "-" || compact === "." || compact === "-.") return null const parsed = Number(compact) return Number.isFinite(parsed) ? parsed : null } return ( <label data-slot="currency-input" className="flex min-w-0 flex-col gap-1.5"> <span className="text-sm font-medium">{label}</span> <span className="relative"> <input {...props} type="text" inputMode="decimal" aria-label={label} value={shown} onFocus={(event) => { setEditing(true) setDraft( current === null ? "" : String(current).replace(".", decimal), ) onFocus?.(event) }} onChange={(event) => { const raw = event.target.value setDraft(raw) if (raw.trim() === "") report(null) else { const parsed = parse(raw) if (parsed !== null) report(parsed) } }} onBlur={(event) => { setEditing(false) const parsed = parse(draft) if (parsed !== null) { const bounded = Math.min( max ?? Infinity, Math.max(min ?? -Infinity, parsed), ) const rounded = Number(bounded.toFixed(digits)) if (rounded !== current) report(rounded) } onBlur?.(event) }} className={cn( "border-input bg-background text-foreground h-9 w-full min-w-0 rounded-md border px-3 pe-14 text-sm tabular-nums outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20", className, )} /> <span aria-hidden="true" className="text-muted-foreground pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-xs font-medium" > {currency} </span> </span> </label> ) } export { CurrencyInput, type CurrencyInputProps }Update the import paths to match your project setup.
Usage
import { CurrencyInput } from "@/components/ballmac/currency-input"The full example is in the Code tab above.
Examples
States and variants
import { CurrencyInput } from "@/components/ballmac/currency-input"
export default function CurrencyInputStates() {
return (
<div className="grid w-full max-w-xs gap-4">
<CurrencyInput
label="Budget in euros"
currency="EUR"
locale="de-DE"
defaultValue={1250.5}
/>
<CurrencyInput
label="Budget in yen"
currency="JPY"
locale="ja-JP"
defaultValue={50000}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
label*Visible and accessible input label. | string | — |
currencyISO 4217 currency code. | string | "USD" |
localeFixed locale for formatting and parsing. | string | — |
valueControlled numeric amount, or null when empty. | number | null | — |
defaultValueInitial numeric amount. | number | null | null |
onValueChangeCalled with the parsed numeric amount while editing. | (value: number | null) => void | — |
minSmallest allowed amount. | number | — |
maxLargest allowed amount. | number | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab / typing | Enters a numeric amount and formats it on blur |
Use with AI
A locale-aware money field that formats on blur while keeping editing and caret behavior simple. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Currency Input (@ballmac/currency-input) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Collect prices, budgets, or payment amounts
Not for
- Use Number Input for unitless quantities
Registry JSON: https://ui.ballmac.com/r/currency-input.json
Credits
Free to use in personal and commercial projects.
- npm
- None
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Number Input
A bounded numeric field with native spinbutton semantics, step buttons, and controlled or uncontrolled state.
Calendar
A date grid for one day, several days or a range, with month and year selects, week numbers, disabled rules and range-end styling, on React DayPicker.
Color Picker
A native color chooser paired with an editable hex field and a live swatch.
Combobox
A searchable single-choice select with groups, descriptions, keywords, clearable value, invalid state and hidden-input form submission.