Currency input
A money field showing the currency symbol and grouped digits at rest, and raw digits under the caret.
A NumberInput with the symbol and the currency's own decimal places filled in from Intl, so JPY takes no decimals and USD takes two; its root reports data-slot currency-input. Every NumberInput prop except prefix, suffix, precision, and format passes through. parseCurrency is exported for reading a typed amount out of pasted text.
Install
$
npx shadcn@latest add @vibra/currency-inputNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { CurrencyInput } from "@/components/ui/currency-input"
import { Label } from "@/components/ui/label"
export default function CurrencyInputDemo() {
const [budget, setBudget] = React.useState<number | null>(2400)
const [credit, setCredit] = React.useState<number | null>(-125.5)
const [retainer, setRetainer] = React.useState<number | null>(180_000)
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="budget">Monthly budget</Label>
<CurrencyInput
id="budget"
value={budget}
onValueChange={setBudget}
step={100}
max={50_000}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="credit">Credit adjustment</Label>
<CurrencyInput
id="credit"
value={credit}
onValueChange={setCredit}
currency="EUR"
locale="de-DE"
allowNegative
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="retainer">Annual retainer</Label>
<CurrencyInput
id="retainer"
size="sm"
value={retainer}
onValueChange={setRetainer}
currency="JPY"
step={10_000}
/>
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| currency | string | "USD" | ISO 4217 code; it picks both the symbol and the number of decimals. |
| locale | string | "en-US" | Locale for the symbol and the grouping separators. |
| allowNegative | boolean | false | Lets the amount go below zero; otherwise the field floors at zero. |
| value | number | null | — | The current amount; setting it makes the field controlled. |
| defaultValue | number | null | — | The starting amount for an uncontrolled field. |
| onValueChange | (value: number | null) => void | — | Called with the new amount, or null once the field is empty. |
| min | number | — | Lower bound; raised to zero unless allowNegative is set. |
| max | number | — | Upper bound, applied on blur and to the steppers. |
| step | number | 1 | How much a stepper or an arrow key moves the amount. |
| size | "sm" | "default" | "default" | sm drops the field to h-7 for dense forms. |
| hideControls | boolean | false | Drops the steppers; the arrow keys still work. |
Dependencies
Registry
Source
"use client"
import * as React from "react"
import { formatNumber } from "@/lib/format"
import { NumberInput, parseNumeric, type NumberInputProps } from "@/components/ui/number-input"
/** Reads an amount out of typed text, keeping only digits, a minus, and a decimal point, e.g. "$1,234.50" → 1234.5; "" and "abc" → null. */
export function parseCurrency(input: string): number | null {
// The same rule the number field itself parses by, named where the docs for
// currency need it.
return parseNumeric(input)
}
export type CurrencyInputProps = Omit<
NumberInputProps,
"prefix" | "suffix" | "precision" | "format" | "data-slot"
> & {
/** ISO 4217 code, e.g. "USD", "EUR", "JPY"; it picks the symbol and the number of decimals. */
currency?: string
locale?: string
/** Lets the amount go below zero; otherwise the field floors at zero. */
allowNegative?: boolean
}
/** A money field: the currency's symbol as a prefix, grouped digits at rest, and raw digits under the caret. */
function CurrencyInput({
currency = "USD",
locale = "en-US",
allowNegative = false,
min,
...props
}: CurrencyInputProps) {
// Intl knows both the symbol and how many decimals the currency uses — two
// for USD and EUR, none for JPY — so neither is hard-coded here.
const { symbol, digits } = React.useMemo(() => {
const formatter = new Intl.NumberFormat(locale, { style: "currency", currency })
const currencyPart = formatter.formatToParts(0).find((part) => part.type === "currency")
return {
symbol: currencyPart?.value ?? currency,
digits: formatter.resolvedOptions().maximumFractionDigits ?? 2,
}
}, [currency, locale])
return (
<NumberInput
data-slot="currency-input"
prefix={symbol}
precision={digits}
format={(value) =>
formatNumber(value, {
locale,
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})
}
min={allowNegative ? min : Math.max(min ?? 0, 0)}
{...props}
/>
)
}
export { CurrencyInput }