AmountInput
- Status
- Implemented
- Family
- 3 of 13
- Registry
- @lopifi/amount-input
npx shadcn@latest add @lopifi/theme @lopifi/amount-inputThe component, rendering
Real data, the shipped component. Switch the theme to check the claims the spec makes below.
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". Nevertype="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
- 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.
"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
- CurrencySelectSearchable, with recents.
- PayeeSelectRecipient picker with fuzzy search and last-paid.
- PaymentFormAmount → payee → method → date → memo.
- PaymentMethodListSelectable method cards.
- CheckoutSummaryItemized total with fees and tax.
- ConfirmPaymentDialogAmount, destination, timing. Hold-to-confirm.
- PaymentStatusTimelineSubmitted → sent → settled, with real timestamps.
- PaymentSuccessThe sanctioned delight moment.
- ReceiptSheetPrintable/shareable receipt.
- PayoutScheduleUpcoming payouts calendar.
- CardInputPAN / expiry / CVC with correct autocomplete.
- BankAccountInputRouting + account with checksum validation.
- RecurringScheduleFrequency, end condition, preview of next 3.
See also
A component is specified in one place, defined in another, and running in a third. All three, from here.
- Payments — the family specThe four-layer specification this component was built against, before it was written.Doc
- Payment forms, methods and receiptsThe family running, with real figures in it, in both themes and at every density.Live
- CatalogAll 179 components, specified before they are written.Doc
- Available balanceSpendable nowTerm
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.
- AmountInput, runningThe payments showcase — live, with theme, density, greyscale and reduced-motion switches.Live
- PaymentsPayments — specs for 14 React components: AmountInput, CurrencySelect, PayeeSelect, PaymentForm and 10 more.Doc
- Currency decimal placesHow many decimals each ISO 4217 code is stored at, and where Intl disagrees.Doc
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.