# Currency Input

> Formatted money input with live thousands separators, decimals on blur, a currency selector and min/max validation.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/currency-input
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/currency-input.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/currency-input-demo.tsx"
"use client"

import * as React from "react"

import { CurrencyInput, type CurrencyCode } from "@/components/currency-input"

export default function CurrencyInputDemo() {
  const [amount, setAmount] = React.useState<number | null>(1250)
  const [currency, setCurrency] = React.useState<CurrencyCode>("USD")

  return (
    <div className="grid w-full max-w-sm grid-cols-[minmax(0,1fr)] gap-6">
      <div className="grid min-w-0 gap-2">
        <CurrencyInput
          label="Invoice total"
          hint="Type freely, we format as you go."
          value={amount}
          onValueChange={setAmount}
          currency={currency}
          onCurrencyChange={setCurrency}
        />
        <p className="text-xs text-muted-foreground tabular-nums">
          Value: {amount === null ? "empty" : amount} {currency}
        </p>
      </div>
      <CurrencyInput
        label="Donation (min 5, max 500)"
        defaultCurrency="EUR"
        defaultValue={2}
        min={5}
        max={500}
        hint="Enter an amount between 5 and 500."
      />
    </div>
  )
}
```

## Source

```tsx title="components/currency-input.tsx"
"use client"

import * as React from "react"
import { ChevronDown } from "lucide-react"

import { cn } from "@/lib/utils"

export type CurrencyCode = "USD" | "EUR" | "CNY" | "JPY"

const currencies: Record<CurrencyCode, { symbol: string; name: string; decimals: number }> = {
  USD: { symbol: "$", name: "US Dollar", decimals: 2 },
  EUR: { symbol: "€", name: "Euro", decimals: 2 },
  CNY: { symbol: "¥", name: "Chinese Yuan", decimals: 2 },
  JPY: { symbol: "¥", name: "Japanese Yen", decimals: 0 },
}

const MAX_INT_DIGITS = 12

function groupDigits(int: string) {
  return int.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
}

/** Keeps digits and one decimal point, adds thousands separators, caps decimals. */
function sanitize(raw: string, decimals: number) {
  const dot = raw.indexOf(".")
  const intRaw = (dot === -1 ? raw : raw.slice(0, dot)).replace(/\D/g, "").slice(0, MAX_INT_DIGITS)
  const fracRaw = dot === -1 ? "" : raw.slice(dot + 1).replace(/\D/g, "").slice(0, decimals)
  const int = intRaw.replace(/^0+(?=\d)/, "")
  const hasDot = dot !== -1 && decimals > 0
  if (!int && !hasDot) return ""
  return `${groupDigits(int || "0")}${hasDot ? `.${fracRaw}` : ""}`
}

function parse(text: string): number | null {
  const n = Number.parseFloat(text.replace(/,/g, ""))
  return Number.isFinite(n) ? n : null
}

function formatFixed(n: number, decimals: number) {
  const fixed = n.toFixed(decimals)
  const [int, frac] = fixed.split(".")
  return `${groupDigits(int)}${frac ? `.${frac}` : ""}`
}

function formatMoney(n: number, code: CurrencyCode) {
  const { symbol, decimals } = currencies[code]
  return `${symbol}${formatFixed(n, decimals)}`
}

export interface CurrencyInputProps
  extends Omit<React.ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "min" | "max" | "type" | "size"> {
  /** Controlled numeric value (null = empty) */
  value?: number | null
  defaultValue?: number | null
  onValueChange?: (value: number | null) => void
  /** Controlled currency */
  currency?: CurrencyCode
  defaultCurrency?: CurrencyCode
  onCurrencyChange?: (currency: CurrencyCode) => void
  /** Hide the currency selector and lock the currency */
  lockCurrency?: boolean
  min?: number
  max?: number
  label?: string
  hint?: string
  /** External error message (takes precedence over min/max validation) */
  error?: string
}

export function CurrencyInput({
  value,
  defaultValue = null,
  onValueChange,
  currency,
  defaultCurrency = "USD",
  onCurrencyChange,
  lockCurrency = false,
  min,
  max,
  label,
  hint,
  error,
  disabled,
  placeholder = "0.00",
  className,
  id,
  ...props
}: CurrencyInputProps) {
  const autoId = React.useId()
  const inputId = id ?? `${autoId}-input`
  const messageId = `${autoId}-message`
  const inputRef = React.useRef<HTMLInputElement>(null)
  const caretRef = React.useRef<number | null>(null)

  const [innerCurrency, setInnerCurrency] = React.useState<CurrencyCode>(defaultCurrency)
  const code = currency ?? innerCurrency
  const { symbol, decimals } = currencies[code]

  const [text, setText] = React.useState(() =>
    (value ?? defaultValue) === null || (value ?? defaultValue) === undefined
      ? ""
      : formatFixed(value ?? (defaultValue as number), currencies[currency ?? defaultCurrency].decimals)
  )
  const [prevValue, setPrevValue] = React.useState(value)
  const [touched, setTouched] = React.useState(false)
  const [focused, setFocused] = React.useState(false)

  // Controlled value changed from outside: re-sync the displayed text during render.
  if (value !== prevValue) {
    setPrevValue(value)
    if (value !== undefined && value !== parse(text)) {
      setText(value === null ? "" : formatFixed(value, decimals))
    }
  }

  // Restore the caret after reformatting (refs are only touched inside the layout effect).
  React.useLayoutEffect(() => {
    const caret = caretRef.current
    if (caret !== null && inputRef.current && document.activeElement === inputRef.current) {
      inputRef.current.setSelectionRange(caret, caret)
    }
    caretRef.current = null
  }, [text])

  const amount = parse(text)

  let message: string | undefined = error
  if (!message && amount !== null) {
    if (min !== undefined && amount < min) message = `Minimum amount is ${formatMoney(min, code)}.`
    else if (max !== undefined && amount > max) message = `Maximum amount is ${formatMoney(max, code)}.`
  }
  const showError = Boolean(error) || (Boolean(message) && (touched || !focused))
  const describedBy = showError || hint ? messageId : undefined

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    const raw = event.target.value
    const caret = event.target.selectionStart ?? raw.length
    const significant = raw.slice(0, caret).replace(/[^\d.]/g, "").length
    const next = sanitize(raw, decimals)

    let position = 0
    let seen = 0
    while (position < next.length && seen < significant) {
      if (next[position] !== ",") seen++
      position++
    }
    while (next[position] === ",") position++
    caretRef.current = position

    setText(next)
    onValueChange?.(parse(next))
  }

  function handleBlur(event: React.FocusEvent<HTMLInputElement>) {
    setFocused(false)
    setTouched(true)
    const n = parse(text)
    if (n !== null) setText(formatFixed(n, decimals))
    else setText("")
    props.onBlur?.(event)
  }

  function handleCurrency(next: CurrencyCode) {
    if (currency === undefined) setInnerCurrency(next)
    onCurrencyChange?.(next)
    const n = parse(text)
    if (n !== null) {
      const d = currencies[next].decimals
      const rounded = Number(n.toFixed(d))
      setText(formatFixed(rounded, d))
      if (rounded !== n) onValueChange?.(rounded)
    }
  }

  return (
    <div className={cn("grid w-full gap-1.5", className)}>
      {label && (
        <label htmlFor={inputId} className="text-sm font-medium">
          {label}
        </label>
      )}
      <div
        className={cn(
          "flex h-11 w-full items-stretch overflow-hidden rounded-lg border bg-background shadow-xs transition-[box-shadow,border-color] focus-within:border-ring focus-within:ring-4 focus-within:ring-ring/40",
          showError && "border-destructive focus-within:border-destructive focus-within:ring-destructive/25",
          disabled && "pointer-events-none opacity-50"
        )}
      >
        <span
          aria-hidden
          className="flex shrink-0 items-center pr-1 pl-3 text-sm font-medium text-muted-foreground"
        >
          {symbol}
        </span>
        <input
          {...props}
          ref={inputRef}
          id={inputId}
          type="text"
          inputMode="decimal"
          autoComplete="off"
          disabled={disabled}
          value={text}
          placeholder={placeholder}
          aria-invalid={showError || undefined}
          aria-describedby={describedBy}
          onChange={handleChange}
          onFocus={(event) => {
            setFocused(true)
            props.onFocus?.(event)
          }}
          onBlur={handleBlur}
          className="h-full min-w-0 flex-1 bg-transparent px-1 text-base tabular-nums outline-none placeholder:text-muted-foreground sm:text-sm"
        />
        <div className="relative flex shrink-0 items-center border-l bg-muted/40">
          {lockCurrency ? (
            <span className="px-3 text-sm font-medium">{code}</span>
          ) : (
            <>
              <select
                aria-label="Currency"
                value={code}
                disabled={disabled}
                onChange={(event) => handleCurrency(event.target.value as CurrencyCode)}
                className="h-full cursor-pointer appearance-none bg-transparent pr-7 pl-3 text-sm font-medium outline-none focus-visible:bg-muted"
              >
                {(Object.keys(currencies) as CurrencyCode[]).map((c) => (
                  <option key={c} value={c}>
                    {c}
                  </option>
                ))}
              </select>
              <ChevronDown className="pointer-events-none absolute right-2 size-4 text-muted-foreground" aria-hidden />
            </>
          )}
        </div>
      </div>
      <p
        id={messageId}
        aria-live="polite"
        className={cn("min-h-4 text-xs", showError ? "text-destructive" : "text-muted-foreground")}
      >
        {showError ? message : hint}
      </p>
    </div>
  )
}
```
