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

PropTypeDefaultWhat it does
labelReactNodenoneVisible label, tied to the input.
valuenumber | nullnoneControlled amount in major units; null is empty. Pair it with onValueChange.
defaultValuenumber | nullnullUncontrolled starting amount.
onValueChange(value: number | null) => voidnoneCalled as the amount changes; null when the field is cleared.
currencystring"USD"ISO 4217 code. Sets the symbol, its side and the number of decimals.
localestring"en-US"Sets the group and decimal separators.
currenciesstring[]noneWhen given, the currency is a small native select inside the control.
onCurrencyChange(currency: string) => voidnoneCalled when the select changes the currency.
minnumbernoneLowest amount; anything lower is pulled up on blur and by the arrow keys.
maxnumbernoneHighest amount; anything higher is pulled down on blur and by the arrow keys.
namestringnoneSubmits a hidden input holding a plain decimal string, such as 12480.50.
hintReactNodenoneHelp text under the field, linked with aria-describedby.
errorReactNodenoneError text; marks the field invalid and is linked with aria-describedby.
...restComponentProps<"input">noneEvery native input attribute, including disabled, placeholder, required and ref. className and style go to the wrapper.

Anatomy

root.eq-money-fieldWrapper that carries className and style.
label.eq-money-field-label
control.eq-money-field-controlThe bordered box around the symbol, input and select.
symbol.eq-money-field-symbolBefore or after the digits, as the locale writes it.
input.eq-money-field-inputA text input with right-aligned tabular digits.
currency.eq-money-field-currencyOnly with currencies.
hint.eq-money-field-hint
error.eq-money-field-error

Keyboard

ArrowUpArrowDownAdds or subtracts 1.
Shift+ArrowUpShift+ArrowDownAdds or subtracts 10.
BackspaceDeleteDeletes 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index