Skip to contentVibraUI
Inputs & filters

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-input

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
currencystring"USD"ISO 4217 code; it picks both the symbol and the number of decimals.
localestring"en-US"Locale for the symbol and the grouping separators.
allowNegativebooleanfalseLets the amount go below zero; otherwise the field floors at zero.
valuenumber | null—The current amount; setting it makes the field controlled.
defaultValuenumber | 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.
minnumber—Lower bound; raised to zero unless allowNegative is set.
maxnumber—Upper bound, applied on blur and to the steppers.
stepnumber1How much a stepper or an arrow key moves the amount.
size"sm" | "default""default"sm drops the field to h-7 for dense forms.
hideControlsbooleanfalseDrops the steppers; the arrow keys still work.

Dependencies

Source

components/ui/currency-input.tsx
"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 }