# React Currency Input: Intl Formatting Without Caret Jumps

> Build a React currency input that formats with Intl as you type, keeps the caret still, puts the symbol where the locale does and submits a plain number.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/react-currency-input

## TL;DR

A good React currency input groups digits with Intl.NumberFormat as the user types, restores the caret by counting digits instead of characters, shows the symbol where the locale puts it ($1,234.56, 1.234,56 €) and gives your code a plain number such as 1234.56. Pad to the currency's decimals and clamp to min and max on blur, never on every keystroke. The Wingo UI Currency Input (Pro) does this with a currency selector and quick amounts, and the free number-format helpers give you the same parsing and caret math for your own field.

A React currency input looks easy until real people type into it. Somebody in Berlin types `1234,5` and expects `1.234,5`, somebody in Chicago types `1234.5` and expects `1,234.5`, and both expect the caret to stay right after the digit they just typed. Your API wants `1234.5` as a number and a currency code next to it. This guide covers what a good money field does, why the caret jumps in hand-rolled versions and how to fix it with Intl, and how to use the Wingo UI [Currency Input](https://wingo-ui.com/components/currency-input) in a form.

## What should a React currency input do?

It formats in the user's locale while they type, keeps the caret where it was, and hands your code a plain number. In detail, seven behaviors:

1. **Format while typing, in the user's locale.** Group separators appear as digits arrive (`1,234` in en-US, `1.234` in de-DE), using the separators `Intl.NumberFormat` reports for that locale.
2. **Keep the caret still.** Inserting a separator must not throw the caret to the end of the field.
3. **Accept either decimal key.** Phone keypads show a dot or a comma depending on the system language, so both should become the locale's decimal when typed at the end.
4. **Put the symbol where the locale does.** `$1,234.56` in en-US, `1.234,56 €` in de-DE, `1.234,56 lei` in ro-RO. The symbol sits next to the text and is never part of the editable value.
5. **Respect the currency's decimals.** USD has 2, JPY has 0, BHD has 3. A yen field ignores the decimal key.
6. **Commit on blur.** Pad `1,234.5` to `1,234.50` and clamp to min and max when the field loses focus, not while the user is mid-number.
7. **One clean value out.** Your state and your form submit get `1234.5`, never `"$1,234.50"`.

## Why does the caret jump in a formatted money input?

Because the string changes length under it. You type `5` after `1,234` and your `onChange` writes `12,345`. React sets the input's value to a new string, the browser has no way to know where your old caret position maps in it, and the caret lands at the end. If you typed in the middle of the number, the next keystroke goes in the wrong place.

The fix is to track digits, not character positions. Before formatting, count the digits to the left of the caret. After formatting, walk the new string and put the caret after the same number of digits. Separators the formatter added or removed never count, so they never move it. The free [number-format helpers](https://wingo-ui.com/components/number-format) ship this approach as `digitsBeforeCaret` and `caretAfterDigits`, next to `formatEditable` (re-formats text mid-typing without dropping a trailing `.` or trailing zeros), `parseNumber` (locale-aware parsing) and `getCurrencyParts` (where the symbol goes).

Here is a minimal money input built only on those helpers:

```tsx
"use client";

import { useLayoutEffect, useRef, useState, type ChangeEvent } from "react";
import {
  caretAfterDigits,
  digitsBeforeCaret,
  formatEditable,
  getCurrencyParts,
  parseNumber,
} from "@/lib/number-format";

type MoneyFieldProps = {
  locale?: string;
  currency?: string;
  onValueChange?: (value: number | null) => void;
};

export function MoneyField({ locale = "en-US", currency = "USD", onValueChange }: MoneyFieldProps) {
  const parts = getCurrencyParts(currency, locale);
  const [text, setText] = useState("");
  const inputRef = useRef<HTMLInputElement>(null);
  const caretRef = useRef<number | null>(null);

  // put the caret back once react has written the new text
  useLayoutEffect(() => {
    if (caretRef.current == null || !inputRef.current) return;
    inputRef.current.setSelectionRange(caretRef.current, caretRef.current);
    caretRef.current = null;
  });

  function handleChange(event: ChangeEvent<HTMLInputElement>) {
    const raw = event.target.value;
    const digits = digitsBeforeCaret(raw, event.target.selectionStart ?? raw.length, locale);
    const next = formatEditable(raw, locale, { maxDecimals: parts.fractionDigits, allowNegative: false });
    caretRef.current = caretAfterDigits(next, digits, locale);
    setText(next);
    onValueChange?.(parseNumber(next, locale));
  }

  return (
    <div className="flex h-10 items-center gap-1 rounded-[10px] bg-foreground/5 px-3 text-sm">
      {parts.position === "prefix" && <span aria-hidden className="text-muted-foreground">{parts.symbol}</span>}
      <input
        ref={inputRef}
        value={text}
        onChange={handleChange}
        inputMode="decimal"
        autoComplete="transaction-amount"
        aria-label={`Amount in ${currency}`}
        className="min-w-0 flex-1 bg-transparent tabular-nums outline-none"
      />
      {parts.position === "suffix" && <span aria-hidden className="text-muted-foreground">{parts.symbol}</span>}
    </div>
  );
}
```

`getCurrencyParts("USD", "en-US")` returns `{ symbol: "$", position: "prefix", spaced: false, fractionDigits: 2 }` and `getCurrencyParts("EUR", "de-DE")` returns the euro sign as a spaced suffix, so the same component lays itself out per locale. Reading separators and symbol position from Intl, instead of hardcoding `,` and `.`, is what lets one Intl.NumberFormat currency input work in Berlin and Chicago alike. `getCurrencyParts` defaults to `narrowSymbol`, the symbol people actually write: in ro-RO, Intl's default `symbol` display prints `1.234,56 RON`, where narrowSymbol prints `1.234,56 lei`.

Try the helpers on their own below. The demo is set to de-DE, so the dot groups thousands and the comma is the decimal. Type in the middle of the number: the grouping changes, the caret does not move, and the rows show the parsed number.

> Live demo (Number format): Click between two digits and type another one: the dots move and the caret stays after the digit you typed. The parseNumber row shows the plain number. Try it at [Number format](https://wingo-ui.com/components/number-format) and install it with `npx wingo-ui@latest add number-format`.

That sketch covers the main path. It does not pad on blur, clamp, step with the arrows, or read a pasted `$1,234.56`. In de-DE, a dot typed after `1.234` is dropped as one more group separator instead of becoming the decimal comma. It does not handle Backspace on a separator either, where the formatter puts the separator straight back and the key seems dead. It also has a quieter bug: type a letter and `formatEditable` returns the same text, `setText` bails out, React writes the old value back into the DOM, and the caret jumps to the end anyway. The full component handles all of these, the last one by restoring the caret itself when a keystroke changes nothing.

## How do you use the Wingo UI Currency Input?

Install it with `npx wingo-ui@latest add currency-input`, then render it with a locale, a value and a `name` for forms:

```tsx
"use client";

import { useState } from "react";
import { CurrencyInput } from "@/components/ui/currency-input";

export function ListingPrice() {
  const [price, setPrice] = useState<number | null>(1250);
  const [currency, setCurrency] = useState("USD");

  return (
    <CurrencyInput
      name="price"
      currencyName="currency"
      label="Price"
      description="The price includes tax"
      locale="en-US"
      currencies={["USD", "EUR", "GBP"]}
      currency={currency}
      onCurrencyChange={setCurrency}
      value={price}
      onValueChange={setPrice}
      min={1}
      error={price !== null && price < 1 ? "The minimum price is $1" : undefined}
      required
    />
  );
}
```

Type an amount below, then open the currency button and pick another code: the symbol changes and the amount stays the same.

> Live demo (Currency Input): Click the price, type 1234.5 and press Tab: it pads to 1,234.50. Click it again, press ArrowUp to add 1, then open the USD button and pick EUR: the symbol changes and the amount is not converted. Try it at [Currency Input](https://wingo-ui.com/components/currency-input) and install it with `npx wingo-ui@latest add currency-input`.

The props that matter most:

- **`value` and `onValueChange`** carry the amount in major units (`1234.56`), with `null` for an empty field. `onValueChange` fires live while typing, unclamped until blur, so validate with that in mind.
- **`locale`** sets the separators and the side of the symbol. Leave it empty and the field reads the `UIProvider` or `LocaleProvider` locale. Without either, the server renders `en-US` and the browser switches to the page language after hydration, so set a locale when the first paint must already be right.
- **`defaultCurrency` / `currency`** take an ISO 4217 code. The decimals follow it (`decimals={-1}`, the default, means "the currency's own"), so USD types two decimals and JPY none.
- **`currencies`** with two or more codes shows a compact code button at the end of the box. Switching currency never converts the amount; if the new currency has fewer decimals, the amount is rounded to them.
- **`min`, `max` and `step`** clamp on blur or Enter, flag the field invalid while out of range, and set what ArrowUp and ArrowDown add (Shift for ten steps).
- **`presets`** renders quick amounts as chips under the field, the pattern donation and tip forms use.
- **`allowNegative`** lets a minus in, for refunds or balance adjustments.

## What value does the currency input submit?

With `name`, the component renders a hidden input whose value is the amount as `"1234.56"` with a dot decimal, or empty when the field is empty. The visible input has no name. `currencyName` adds a second hidden input with the chosen code. So a plain form or a Server Action reads numbers, not formatted text:

```tsx
import { CurrencyInput } from "@/components/ui/currency-input";
import { Button } from "@/components/ui/button";

async function savePrice(formData: FormData) {
  "use server";
  const amount = Number(formData.get("price"));
  const currency = String(formData.get("currency") ?? "USD");
  if (!Number.isFinite(amount) || amount < 1) return;
  // store minor units to keep float math out of the database
  const cents = Math.round(amount * 100);
  console.log("save", cents, currency);
}

export function PriceForm() {
  return (
    <form action={savePrice} className="flex flex-col gap-4">
      <CurrencyInput name="price" currencyName="currency" currencies={["USD", "EUR"]} label="Price" locale="en-US" min={1} required />
      <Button type="submit">Save price</Button>
    </form>
  );
}
```

The hidden input updates in the same event as the keystroke, so pressing Enter in the field pads the amount and submits the new value together. Round, never truncate: `1.15 * 100` is `114.99999999999999` in JavaScript, so `Math.trunc` gives 114 where `Math.round` gives 115. The `* 100` assumes a two-decimal currency; for JPY or BHD multiply by `10 ** decimals` instead.

## How does it compare with react-currency-input-field and a shadcn currency input?

react-currency-input-field is the free dedicated package, react-number-format is the general-purpose formatter, shadcn/ui has no money field, and the Wingo UI component is a Pro source file with the selector and phone behavior built in. As of October 2026, from npm and each project's docs:

| Option | Version | License | Value out | Notes |
|---|---|---|---|---|
| [react-currency-input-field](https://github.com/cchanxzy/react-currency-input-field) | 4.0.14 | MIT | string, plus `values.float` | `intlConfig` for locale and currency, k / m / b abbreviations on by default |
| [react-number-format](https://www.npmjs.com/package/react-number-format) | 5.4.5 | MIT | `floatValue` in `onValueChange` | general number formatting and masks, bring your own styling |
| [shadcn/ui](https://ui.shadcn.com/docs/components) | n/a | MIT | n/a | no currency or number input in the official list; Input and Input Group exist |
| Wingo UI Currency Input | copied source | Pro | number in state, `"1234.56"` in the form | currency selector, presets, bottom sheet list on phones |

If you want a free npm package for a react currency input field and are happy to style it, react-currency-input-field is the dedicated choice: read `values.float` from its `onValueChange` for the number, since its first argument is a string, and set `disableAbbreviations` if a typed `k` should not mean thousands. Search for a shadcn currency input or a React money input example and the usual shortcut is a `type="number"` Input with a dollar icon in an Input Group. That gives you no grouping and no locale. The Wingo UI component fits when you want the field, the label and error anatomy, and the phone behavior in one file you own.

## How does the currency input work on mobile and with a keyboard?

On phones the field uses `inputMode="decimal"` for the decimal keypad and `enterKeyHint="done"`, and either separator key is accepted, since the keypad shows whichever the system language uses. The text is 16px so iOS Safari never zooms into it, the md box grows to 44px on touch screens, and the currency button and the preset chips have 44px hit areas. Below 768px the currency list opens as a bottom sheet instead of a popover (`responsive={false}` keeps the popover). iOS decimal keypads have no minus key, so with `allowNegative` a negative amount comes from ArrowDown or a hardware keyboard.

With a keyboard, focusing the field selects the amount so typing replaces it (`selectOnFocus`, on by default). ArrowUp and ArrowDown step, Enter commits and still submits, and with `clearable` Escape empties the field. On the currency button, Enter, Space or the arrows open the list, where typing a letter jumps to a currency ("u" to USD). The symbol is `aria-hidden`, and a visually hidden "Amount in US dollars" is linked through `aria-describedby`, so a screen reader hears the currency once with the field instead of a stray dollar sign.

## Should you use the Number Input for money instead?

Use the Number Input when people step a value, and the Currency Input when they type an amount from scratch. The [Number Input](https://wingo-ui.com/components/number-input) takes a `currency` prop too and formats with Intl, but it works the other way around: at rest it shows `$125,000`, and on focus it turns into the plain number for editing. It adds stepper buttons with hold to repeat, the mouse wheel and Home / End to jump to min and max. That fits a quantity, a budget or a price you nudge in steps of 1,000. For an amount people type from scratch, such as a payment, a donation or a listing price, the Currency Input's live grouping, currency selector and presets fit better.

## Which currency input pitfalls show up in production?

Most money bugs come from treating the formatted text as data or doing math on raw floats. The usual ones:

- **Storing the formatted string.** `"$1,234.50"` cannot be summed or compared. Store the number and the currency code as two fields.
- **Float math on money.** `0.1 + 0.2` is `0.30000000000000004` in JavaScript. Round to the currency's decimals before saving, or store integer minor units. The number-format helpers include `roundTo(0.1 + 0.2, 2)`, which gives `0.3`.
- **Clamping while typing.** Clamping `5` to a minimum of `10` on every keystroke makes `50` impossible to type. Flag it invalid while typing and clamp on blur.
- **Parsing with `parseFloat`.** `parseFloat("1.234,56")` is `1.234` and `Number("1,234.56")` is `NaN`. Parse with the locale's separators (`parseNumber(text, locale)`) or read the hidden input, which is always a dot decimal.
- **Formatting with an undefined locale.** `new Intl.NumberFormat()` uses the server's language on the server and the browser's in the browser, so `1,234.5` and `1.234,5` disagree and hydration fails. Pass a locale, or wrap the app in `LocaleProvider` from `@/hooks/use-locale`.
- **Converting on currency change.** Switching the currency in a form usually fixes a wrong pick. Keep the amount and let the user edit it.

For invoices, where each line amount feeds a VAT total, our [e-Factura VAT calculation guide](https://wingo-ui.com/blog/e-factura-vat-calculation-javascript) shows the rounding per rate. The money field is one of many inputs covered in our [React form components guide](https://wingo-ui.com/blog/react-form-components-guide), and more single-component deep dives live under [component guides](https://wingo-ui.com/blog/category/components).

## How do you install the Currency Input?

Run `npx wingo-ui@latest add currency-input`. It copies the Currency Input plus the free parts it builds on (Field, Select, Button and the number-format, use-locale and motion helpers) and installs the npm packages they import, such as `motion`, `lucide-react` and `vaul` for the phone sheet. The Currency Input and the Number Input are part of [Wingo UI Pro](https://wingo-ui.com/pricing), so sign in first with `npx wingo-ui@latest login`; `npx wingo-ui@latest add number-format` on its own is free.

## Components in this post

- [Currency Input](https://wingo-ui.com/components/currency-input) (Pro): A React currency input: a money field formatted per locale as you type, with an optional currency selector and quick amount presets. Install: `npx wingo-ui@latest add currency-input`
- [Number Input](https://wingo-ui.com/components/number-input) (Pro): A React number input to type or step values: steppers with hold to repeat, min, max and step, and Intl formatting for money and units. Install: `npx wingo-ui@latest add number-input`
- [Number format](https://wingo-ui.com/components/number-format) (Free): Locale-aware number helpers: cached Intl formatting, parsing what people type, step math and currency affixes. Install: `npx wingo-ui@latest add number-format`

## FAQ

### How do I format a currency input in React?

Keep the text in state, re-format it on every change with Intl separators for the locale, and restore the caret by counting the digits before it, then placing it after the same count in the new text. Store the parsed number separately and pad the decimals only on blur.

### Why does the cursor jump to the end in my currency input?

When onChange writes a new string with an added or removed group separator, the browser cannot map the old caret position into it, so it moves to the end. Count the digits before the caret, re-format, then call setSelectionRange after the same digit count in a layout effect.

### Is there a shadcn currency input?

Not in the official shadcn/ui component list as of October 2026, which has Input and Input Group but no currency or number input. A common shortcut is a type="number" Input with a dollar icon inside an Input Group, which gives you no grouping and no locale.

### Should I use type="number" for a money input in React?

No. A number input cannot show thousands separators or a currency symbol, its value is an empty string while the text is invalid, and the mouse wheel can change it by accident. Use a text input with inputMode="decimal" and parse the text yourself.

### How should I store a currency amount?

Store the number and the ISO 4217 currency code as two fields, never the formatted string. Many teams store integer minor units (cents) in the database to avoid float math; if you keep decimals in JavaScript, round to the currency's decimals before saving.

---

Source: https://wingo-ui.com/blog/react-currency-input
