Skip to content

AmountInput

The hardest input in fintech.
Implemented
Status
Implemented
Family
3 of 13
Registry
@lopifi/amount-input
npx shadcn@latest add @lopifi/theme @lopifi/amount-input

The component, rendering

Real data, the shipped component. Switch the theme to check the claims the spec makes below.

Preview

The four-layer spec

The hardest input in fintech. Get this wrong and nothing else matters.

<AmountInput
  currency="USD"
  value={amount}
  onChange={setAmount}
  max={availableBalance}
  onMaxClick={() => setAmount(availableBalance)}
/>

The rules

  • type="text" + inputMode="decimal". Never type="number" — it has scroll-wheel increment (which will silently change someone's payment amount), inconsistent decimal parsing across locales, and unstyleable spinners.
  • 16px minimum font size or iOS Safari zooms the viewport on focus and never zooms back.
  • Parse permissively, format on blur. Accept 1,234.56, 1234,56, 1 234.56, $1234.56, 1.2k. Reject nothing a human would type. Format canonically when they leave the field.
  • Never reformat mid-typing. Inserting a thousands separator while someone is typing moves the caret and is the single most infuriating input bug in fintech. Format on blur only.
  • Caret preservation if you must transform during input — measure the caret offset from the right, not the left.
  • Never block paste.
  • Locale-aware decimal separator. A German user types 1234,56. Accept it.
  • Currency-aware precision. JPY rejects decimals entirely; KWD accepts three.
  • Max is soft by default. Show "Exceeds available balance of $4,120.00" as an inline error, but let them type it — an overdraft may be legitimate, and blocking input to prevent an invalid state prevents valid ones too.
  • Big. figure-lg (28/32) or larger. This is the most important number on the screen and it should look like it.
  • The symbol is part of the field, at --text-tertiary, not a floating adornment that misaligns as the value grows.

Accessibility

aria-label="Amount in US dollars" · parsed value announced on blur ("one thousand two hundred thirty-four dollars and fifty-six cents") via aria-describedby · errors specific and actionable · aria-invalid on error.

Motion

None on the value. Error message: 140ms opacity + 4px translate. Shake on invalid submit is forbidden — it's a stress signal in a context where the user is already anxious about money.

Lifted verbatim from Payments. The doc and this page are the same paragraphs, so they cannot disagree.

What lands in your repository

One component is never one file. Everything below is copied into your project, where you own it.

Files

components/ledger/amount-input.tsx

Lopifi dependencies · 6
buttoncnformat-moneymoneymoney.typestooltip
npm packages
@base-ui-components/reactclass-variance-authorityclsxtailwind-merge
Registry entry
/r/amount-input.json

The whole component

No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.

components/ledger/amount-input.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/ledger/cn";
import {
  currencyScale,
  currencySymbol,
  formatMoneyInput,
  moneyAriaLabel,
  parseMoneyInput,
} from "@/lib/ledger/format-money";
import type { MoneyValue } from "@/lib/ledger/money.types";
import { Button } from "@/components/ledger/button";
import { Money } from "@/components/ledger/money";

export interface AmountInputProps
  extends Omit<React.ComponentPropsWithoutRef<"input">, "value" | "onChange" | "type" | "max"> {
  currency: string;
  error?: string;
  label?: string;
  locale?: string;
  max?: MoneyValue;
  onMaxClick?: () => void;
  onValueChange: (minorUnits: number | null) => void;
  /** Minor units, or null for empty. */
  value: number | null;
}

/**
 * The hardest input in fintech.
 *
 * `type="text"` + `inputMode="decimal"`, never `type="number"` — the scroll
 * wheel on a number input will silently change someone's payment amount.
 * Parse permissively, format on blur only: inserting a thousands separator
 * mid-typing moves the caret and is the single most infuriating input bug
 * in this domain. Paste is never blocked. Max is soft — an overdraft may be
 * legitimate, and blocking input to prevent an invalid state prevents valid
 * ones too.
 */
export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
  (
    {
      className,
      currency,
      value,
      onValueChange,
      max,
      onMaxClick,
      label = "Amount",
      locale = "en-US",
      error,
      id,
      ...props
    },
    ref,
  ) => {
    const reactId = React.useId();
    const inputId = id ?? reactId;
    const describedBy = `${inputId}-parsed`;
    const errorId = `${inputId}-error`;

    const [draft, setDraft] = React.useState(() =>
      value == null ? "" : formatMoneyInput(value, currency, locale),
    );
    const [focused, setFocused] = React.useState(false);
    const scale = currencyScale(currency, locale);
    const symbol = currencySymbol(currency, locale);

    // Reformat when the value changes from outside (a Max click, a swap flip),
    // but never while the field is being typed into.
    React.useEffect(() => {
      if (focused) return;
      setDraft(value == null ? "" : formatMoneyInput(value, currency, locale));
    }, [value, currency, locale, focused]);

    const exceedsMax = max != null && value != null && value > Number(max.amount);

    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      const next = e.target.value;
      setDraft(next);
      const parsed = parseMoneyInput(next, currency, locale);
      onValueChange(next.trim() === "" ? null : parsed);
    };

    const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
      setFocused(false);
      const parsed = parseMoneyInput(draft, currency, locale);
      if (parsed == null) {
        setDraft("");
        onValueChange(null);
      } else {
        setDraft(formatMoneyInput(parsed, currency, locale));
        onValueChange(parsed);
      }
      props.onBlur?.(e);
    };

    const inlineError =
      error ??
      (exceedsMax && max
        ? `Exceeds available balance of ${formatMoneyInput(Number(max.amount), max.currency, locale)} ${max.currency}`
        : undefined);

    return (
      <div className={cn("w-full", className)}>
        <div className="flex items-baseline justify-between">
          <label className="text-label font-medium text-text-secondary" htmlFor={inputId}>
            {label}
          </label>
          {max ? (
            <span className="text-caption text-text-secondary">
              Balance <Money size="xs" tone="inherit" value={max} />
            </span>
          ) : null}
        </div>

        <div
          className={cn(
            "mt-2 flex items-center gap-2 rounded-md border bg-surface px-4",
            "transition-colors duration-1 ease-standard",
            inlineError ? "border-negative" : "border-border hover:border-border-strong",
            "focus-within:border-accent",
          )}
        >
          {/* The symbol is part of the field, not a floating adornment that
              misaligns as the value grows. */}
          <span aria-hidden="true" className="numeric text-figure-lg text-text-tertiary">
            {symbol}
          </span>
          <input
            {...props}
            aria-describedby={cn(describedBy, inlineError && errorId)}
            aria-invalid={inlineError ? true : undefined}
            aria-label={`${label} in ${currency}`}
            autoComplete="off"
            /* 16px minimum or iOS Safari zooms the viewport and never zooms back. */
            className={cn(
              "numeric h-16 w-0 min-w-0 flex-1 bg-transparent text-figure-lg font-medium",
              "outline-none placeholder:text-text-disabled",
            )}
            id={inputId}
            inputMode="decimal"
            onBlur={handleBlur}
            onChange={handleChange}
            onFocus={(e) => {
              setFocused(true);
              props.onFocus?.(e);
            }}
            placeholder={scale === 0 ? "0" : `0.${"0".repeat(scale)}`}
            ref={ref}
            type="text"
            value={draft}
          />
          {onMaxClick && max ? (
            <Button onClick={onMaxClick} size="sm" variant="ghost">
              Max
            </Button>
          ) : null}
        </div>

        {/* The parsed value, announced on blur. */}
        <span className="sr-only" id={describedBy}>
          {value == null ? "No amount entered" : moneyAriaLabel({ amount: value, currency }, { locale })}
        </span>

        {inlineError ? (
          // 140ms opacity + 4px translate. A shake is forbidden: it is a stress
          // signal in a context where the user is already anxious about money.
          <p className="ledger-crossfade mt-2 text-caption text-negative" id={errorId} role="alert">
            {inlineError}
          </p>
        ) : null}
      </div>
    );
  },
);
AmountInput.displayName = "AmountInput";

The accounting it encodes

Concepts this component renders. Each one is defined once, in the lexicon, and used identically in the code and the copy.

Also in Payments

See also

A component is specified in one place, defined in another, and running in a third. All three, from here.

Elsewhere in the system

Where AmountInput sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.

Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.

Told when Payments changes

Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.