Inputs
Money field
An amount input that groups digits as you type, keeps the caret in place and knows each currency's decimals.
- Built on
Intl.NumberFormat<input inputmode="decimal"><select>:has()- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.8 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { MoneyField } from "@/components/equip/money-field";
export default function Demo() {
return (
<div style={{ width: 210 }}>
<MoneyField label="Invoice amount" defaultValue={12480.5} currencies={["USD", "EUR", "GBP"]} name="amount" />
</div>
);
}Your versionchange its options in Look, This part
Examples
Locales and currencies
Grouping, the decimal separator, the symbol's side and the number of decimals all come from Intl; yen has none.
Code
import { MoneyField } from "@/components/equip/money-field";
export default function Example() {
return (
<div style={{ display: "grid", gap: 12, width: 240 }}>
<MoneyField label="Monatsmiete" locale="de-DE" currency="EUR" defaultValue={1450} />
<MoneyField label="Hotel deposit" locale="ja-JP" currency="JPY" defaultValue={32000} />
</div>
);
}Limits and errors
Out-of-range amounts are pulled back to the limit on blur; the error here is the app's own check.
Order total was $249.00
Code
"use client";
import { useState } from "react";
import { MoneyField } from "@/components/equip/money-field";
export default function Example() {
const [refund, setRefund] = useState<number | null>(null);
const tooHigh = refund !== null && refund > 249;
return (
<div style={{ width: 240 }}>
<MoneyField
label="Refund"
value={refund}
onValueChange={setRefund}
min={0}
hint="Order total was $249.00"
error={tooHigh ? "A refund cannot exceed the order total" : undefined}
placeholder="0.00"
/>
</div>
);
}Disabled
Set by your billing admin
Code
import { MoneyField } from "@/components/equip/money-field";
export default function Example() {
return (
<div style={{ width: 240 }}>
<MoneyField label="Plan price" currency="GBP" locale="en-GB" defaultValue={49} hint="Set by your billing admin" disabled />
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Visible label, tied to the input. |
value | number | null | none | Controlled amount in major units; null is empty. Pair it with onValueChange. |
defaultValue | number | null | null | Uncontrolled starting amount. |
onValueChange | (value: number | null) => void | none | Called as the amount changes; null when the field is cleared. |
currency | string | "USD" | ISO 4217 code. Sets the symbol, its side and the number of decimals. |
locale | string | "en-US" | Sets the group and decimal separators. |
currencies | string[] | none | When given, the currency is a small native select inside the control. |
onCurrencyChange | (currency: string) => void | none | Called when the select changes the currency. |
min | number | none | Lowest amount; anything lower is pulled up on blur and by the arrow keys. |
max | number | none | Highest amount; anything higher is pulled down on blur and by the arrow keys. |
name | string | none | Submits a hidden input holding a plain decimal string, such as 12480.50. |
hint | ReactNode | none | Help text under the field, linked with aria-describedby. |
error | ReactNode | none | Error text; marks the field invalid and is linked with aria-describedby. |
...rest | ComponentProps<"input"> | none | Every native input attribute, including disabled, placeholder, required and ref. className and style go to the wrapper. |
Anatomy
| root | .eq-money-field | Wrapper that carries className and style. |
|---|---|---|
| label | .eq-money-field-label | |
| control | .eq-money-field-control | The bordered box around the symbol, input and select. |
| symbol | .eq-money-field-symbol | Before or after the digits, as the locale writes it. |
| input | .eq-money-field-input | A text input with right-aligned tabular digits. |
| currency | .eq-money-field-currency | Only with currencies. |
| hint | .eq-money-field-hint | |
| error | .eq-money-field-error |
Keyboard
| ArrowUpArrowDown | Adds or subtracts 1. |
|---|---|
| Shift+ArrowUpShift+ArrowDown | Adds or subtracts 10. |
| BackspaceDelete | Deletes the digit next to the caret, stepping over a group separator. |
Notes
- Accepts only digits and one decimal separator; anything else typed or pasted is dropped.
- The caret is put back after the same number of digits it followed, so grouping never makes it jump.
- Decimals are padded on blur, and currencies without minor units, such as JPY, take no decimal separator.
- Negative amounts are not supported; use a separate debit and credit choice instead.
- Changing the currency rounds the amount to the new currency's decimals.
- The symbol is hidden from assistive tech; name the currency in the label or use the select when it matters.
- Formatting comes from Intl on both server and client, so a locale must be available in both to match.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/money-field.tsx266 lines
"use client";
import { useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent, ComponentProps, FocusEvent, KeyboardEvent, ReactNode } from "react";
import "./money-field.css";
export type MoneyFieldProps = Omit<
ComponentProps<"input">,
"type" | "value" | "defaultValue" | "onChange" | "min" | "max" | "name" | "children"
> & {
label: ReactNode;
/** Amount in major units (12.5 is twelve dollars fifty). null is empty. */
value?: number | null;
defaultValue?: number | null;
onValueChange?: (value: number | null) => void;
/** ISO 4217 code. */
currency?: string;
locale?: string;
/** When given, the currency becomes a small native select inside the control. */
currencies?: string[];
onCurrencyChange?: (currency: string) => void;
min?: number;
max?: number;
/** Submits a hidden input holding a plain decimal string, such as 12480.50. */
name?: string;
hint?: ReactNode;
error?: ReactNode;
};
type Shape = {
digits: number;
decimal: string;
group: string;
symbol: string;
symbolFirst: boolean;
grouper: Intl.NumberFormat;
};
function shapeOf(locale: string, currency: string): Shape {
const money = new Intl.NumberFormat(locale, { style: "currency", currency, numberingSystem: "latn" });
const parts = money.formatToParts(1234567.5);
const find = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
const symbolAt = parts.findIndex((p) => p.type === "currency");
const intAt = parts.findIndex((p) => p.type === "integer");
return {
digits: money.resolvedOptions().maximumFractionDigits ?? 2,
decimal: new Intl.NumberFormat(locale).formatToParts(1.5).find((p) => p.type === "decimal")?.value ?? ".",
group: find("group"),
symbol: find("currency"),
symbolFirst: symbolAt < intAt,
grouper: new Intl.NumberFormat(locale, { maximumFractionDigits: 0, numberingSystem: "latn" }),
};
}
/** Plain draft ("12480.5") into display text ("12,480.5"), keeping a trailing separator. */
function display(plain: string, shape: Shape) {
if (plain === "") return "";
const [whole, fraction] = plain.split(".");
const digits = whole.replace(/^0+(?=\d)/, "");
const grouped = digits === "" ? "" : shape.grouper.format(BigInt(digits));
return fraction === undefined ? grouped : `${grouped}${shape.decimal}${fraction}`;
}
/** Display text into a plain draft, keeping only digits and the first decimal separator. */
function plainOf(text: string, shape: Shape) {
let out = "";
let seen = false;
for (const char of text) {
if (char >= "0" && char <= "9") out += char;
else if (char === shape.decimal && shape.digits > 0 && !seen) {
out += ".";
seen = true;
}
}
const [whole, fraction] = out.split(".");
return fraction === undefined ? whole : `${whole}.${fraction.slice(0, shape.digits)}`;
}
const numberOf = (plain: string) => (plain === "" || plain === "." ? null : Number(plain));
const padded = (n: number | null, shape: Shape) => (n === null ? "" : n.toFixed(shape.digits));
const significant = (char: string, shape: Shape) => (char >= "0" && char <= "9") || char === shape.decimal;
export function MoneyField({
label,
value,
defaultValue = null,
onValueChange,
currency = "USD",
locale = "en-US",
currencies,
onCurrencyChange,
min,
max,
name,
hint,
error,
disabled,
className,
style,
id,
onBlur,
onKeyDown,
...rest
}: MoneyFieldProps) {
const autoId = useId();
const inputId = id ?? autoId;
const inputRef = useRef<HTMLInputElement>(null);
const caret = useRef<number | null>(null);
// Forces a render when a keystroke is rejected, so the caret is still put back.
const [, bump] = useState(0);
const [code, setCode] = useState(currency);
const shape = useMemo(() => shapeOf(locale, code), [locale, code]);
const [plain, setPlain] = useState(() => padded(value !== undefined ? value : defaultValue, shape));
const [seenCurrency, setSeenCurrency] = useState(currency);
if (currency !== seenCurrency) {
setSeenCurrency(currency);
setCode(currency);
const n = numberOf(plain);
if (n !== null) setPlain(n.toFixed(shapeOf(locale, currency).digits));
}
const [seenValue, setSeenValue] = useState(value);
if (value !== seenValue) {
setSeenValue(value);
if (value !== undefined && value !== numberOf(plain)) setPlain(padded(value, shape));
}
const amount = numberOf(plain);
useLayoutEffect(() => {
const input = inputRef.current;
if (caret.current === null || !input) return;
input.setSelectionRange(caret.current, caret.current);
caret.current = null;
});
function update(next: string, count?: number) {
setPlain(next);
const n = numberOf(next);
if (n !== amount) onValueChange?.(n);
if (count === undefined) return;
// Put the caret back after the same number of digits it followed before formatting.
const text = display(next, shape);
let seen = 0;
let at = 0;
while (at < text.length && seen < count) {
if (significant(text[at], shape)) seen += 1;
at += 1;
}
caret.current = at;
}
function clamp(n: number) {
return Math.min(max ?? Infinity, Math.max(min ?? 0, n));
}
function handleChange(event: ChangeEvent<HTMLInputElement>) {
const input = event.currentTarget;
const before = input.value.slice(0, input.selectionStart ?? input.value.length);
const count = plainOf(before, shape).length;
update(plainOf(input.value, shape), count);
bump((n) => n + 1);
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
const input = event.currentTarget;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
// Deleting a group separator would be undone by formatting, so step over it and delete the digit.
if (start === end && shape.group) {
if (event.key === "Backspace" && input.value[start - 1] === shape.group) input.setSelectionRange(start - 1, start - 1);
if (event.key === "Delete" && input.value[start] === shape.group) input.setSelectionRange(start + 1, start + 1);
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
const by = (event.shiftKey ? 10 : 1) * (event.key === "ArrowUp" ? 1 : -1);
const next = padded(clamp((amount ?? 0) + by), shape);
update(next, plainOf(display(next, shape), shape).length);
}
}
function handleBlur(event: FocusEvent<HTMLInputElement>) {
onBlur?.(event);
if (amount !== null) update(padded(clamp(amount), shape));
}
function handleCurrency(event: ChangeEvent<HTMLSelectElement>) {
const next = event.currentTarget.value;
setCode(next);
onCurrencyChange?.(next);
if (amount !== null) {
const digits = shapeOf(locale, next).digits;
const rounded = Number(amount.toFixed(digits));
setPlain(rounded.toFixed(digits));
if (rounded !== amount) onValueChange?.(rounded);
}
}
const hintId = hint ? `${inputId}-hint` : undefined;
const errorId = error ? `${inputId}-error` : undefined;
const symbol = (
<span className="eq-money-field-symbol" aria-hidden="true">
{shape.symbol}
</span>
);
return (
<div
className={["eq-money-field", className].filter(Boolean).join(" ")}
style={style}
data-disabled={disabled || undefined}
>
<label className="eq-money-field-label" htmlFor={inputId}>
{label}
</label>
<div className="eq-money-field-control" data-invalid={error ? true : undefined}>
{shape.symbolFirst && symbol}
<input
inputMode={shape.digits > 0 ? "decimal" : "numeric"}
autoComplete="off"
aria-describedby={[hintId, errorId].filter(Boolean).join(" ") || undefined}
aria-invalid={error ? true : undefined}
{...rest}
ref={inputRef}
id={inputId}
className="eq-money-field-input"
type="text"
value={display(plain, shape)}
disabled={disabled}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
{!shape.symbolFirst && symbol}
{currencies && currencies.length > 0 && (
<select
className="eq-money-field-currency"
aria-label="Currency"
value={code}
disabled={disabled}
onChange={handleCurrency}
>
{currencies.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
)}
</div>
{hint && (
<p className="eq-money-field-hint" id={hintId}>
{hint}
</p>
)}
{error && (
<p className="eq-money-field-error" id={errorId}>
{error}
</p>
)}
{name && <input type="hidden" name={name} value={padded(amount, shape)} disabled={disabled} />}
</div>
);
}components/equip/money-field.css118 lines
@layer equip {
.eq-money-field {
display: grid;
/* One column that may shrink below the input's natural width. */
grid-template-columns: minmax(0, 1fr);
gap: var(--eq-space-1);
min-width: 0;
}
.eq-money-field-label {
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
/* The border sits on the wrapper, so the symbol and the currency select read as part of the field. */
.eq-money-field-control {
display: flex;
align-items: center;
gap: var(--eq-space-2);
height: var(--eq-h-m);
padding-inline: var(--eq-space-3) var(--eq-space-1);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
color: var(--eq-ink);
transition: border-color var(--eq-dur) var(--eq-ease);
}
.eq-money-field-control:not(:has(.eq-money-field-currency)) {
padding-inline-end: var(--eq-space-3);
}
.eq-money-field-control:has(.eq-money-field-input:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
.eq-money-field-control[data-invalid] {
border-color: var(--eq-danger);
}
.eq-money-field-symbol {
flex: none;
color: var(--eq-ink-2);
font: var(--eq-weight) var(--eq-text-m) / 1 var(--eq-font);
}
.eq-money-field-input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: var(--eq-weight) var(--eq-text-m) / 1 var(--eq-font);
font-variant-numeric: tabular-nums;
text-align: end;
}
.eq-money-field-input:focus-visible {
outline: none;
}
.eq-money-field-input::placeholder {
color: var(--eq-ink-3);
}
.eq-money-field-currency {
flex: none;
height: 24px;
padding-inline: var(--eq-space-1);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-s);
background: var(--eq-tint);
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
.eq-money-field-currency:hover:not(:disabled) {
background: var(--eq-tint-strong);
}
}
.eq-money-field-currency:active:not(:disabled) {
scale: var(--eq-press);
}
.eq-money-field-currency:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-money-field-hint {
margin: 0;
color: var(--eq-ink-2);
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-money-field-error {
margin: 0;
color: var(--eq-danger);
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-money-field[data-disabled] {
opacity: 0.45;
}
.eq-money-field[data-disabled] :is(.eq-money-field-input, .eq-money-field-currency) {
cursor: not-allowed;
}
}