Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Component guides
  4. React Currency Input: Intl Formatting Without Caret Jumps
Auf Deutsch lesen
  1. Blog
  2. Component guides
  3. React Currency Input: Intl Formatting Without Caret Jumps
Component guides
Component guides

React Currency Input: Intl Formatting Without Caret Jumps

SR
Serban Rusu · Founder of Wingo UI
Oct 16, 2026 · 10 min read

On this page

0%
  1. What should a React currency input do?
  2. Why does the caret jump in a formatted money input?
  3. How do you use the Wingo UI Currency Input?
  4. What value does the currency input submit?
  5. How does it compare with react-currency-input-field and a shadcn currency input?
  6. How does the currency input work on mobile and with a keyboard?
  7. Should you use the Number Input for money instead?
  8. Which currency input pitfalls show up in production?
  9. How do you install the Currency Input?
  10. FAQ
    1. How do I format a currency input in React?
    2. Why does the cursor jump to the end in my currency input?
    3. Is there a shadcn currency input?
    4. Should I use type="number" for a money input in React?
    5. How should I store a currency amount?

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.

Published Oct 16, 2026

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

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
$ npx wingo-ui@latest add number-format
FreeNumber format docs

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.

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
$ npx wingo-ui@latest add currency-input
ProCurrency Input docs

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:

OptionVersionLicenseValue outNotes
react-currency-input-field (opens in a new tab)4.0.14MITstring, plus values.floatintlConfig for locale and currency, k / m / b abbreviations on by default
react-number-format (opens in a new tab)5.4.5MITfloatValue in onValueChangegeneral number formatting and masks, bring your own styling
shadcn/ui (opens in a new tab)n/aMITn/ano currency or number input in the official list; Input and Input Group exist
Wingo UI Currency Inputcopied sourcePronumber in state, "1234.56" in the formcurrency 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 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 shows the rounding per rate. The money field is one of many inputs covered in our React form components guide, and more single-component deep dives live under component guides.

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

    A React currency input: a money field formatted per locale as you type, with an optional currency selector and quick amount presets.

    Pro
  • Number Input

    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.

    Pro
  • Number format

    Locale-aware number helpers: cached Intl formatting, parsing what people type, step math and currency affixes.

    Free

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.

  • Forms
  • Currency input
  • Intl
  • React

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Build it: screens and apps

E-Factura VAT Calculation in TypeScript and React

E-Factura VAT calculation in TypeScript: Romania's 21% and 11% rates, VAT rounded once per rate, the totals ANAF checks to the cent and a React line editor.

SRSerban Rusu·Oct 9, 2026·11 min read
Component guides

Embla Carousel React: Accessible Slides, No Layout Shift

Build an embla carousel react component for photos, listings, testimonials and onboarding, with keyboard controls, pausable autoplay and no layout shift.

SRSerban Rusu·Oct 19, 2026·9 min read
Component guides

React Animated Number Counter: Rolling Odometer Digits

Build a React animated number counter that rolls each digit like an odometer, takes its separators from Intl, holds its width and respects reduced motion.

SRSerban Rusu·Oct 19, 2026·9 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI