A treasury-grade amount field: token-denominated input, token select that re-prices everything, an odometer fiat readout, quick percent fills, and polite balance validation.
npx shadcn@latest add @paragon/token-amount-inputAlso installs: digit-roll
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { DigitRoll } from "@/registry/paragon/ui/digit-roll";
export interface TokenDef {
/** Ticker shown in the field, e.g. "USDC". */
symbol: string;
/** Full name for the dropdown row. */
name: string;
/** Available balance, in token units. */
balance: number;
/** Fiat price per token. */
usdPrice: number;
/** Fraction digits this token is entered in. */
decimals?: number;
/** Dot color for the built-in badge; any CSS color. */
color?: string;
/** Custom badge, replaces the dot. */
icon?: React.ReactNode;
}
function formatToken(value: number, decimals: number) {
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
}).format(value);
}
export interface TokenAmountInputProps
extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
/** Available tokens. The first is the default. */
tokens: TokenDef[];
/** Controlled amount, in the selected token's units. */
value?: number;
defaultValue?: number;
onValueChange?: (amount: number) => void;
/** Controlled token symbol. */
token?: string;
defaultToken?: string;
onTokenChange?: (symbol: string) => void;
/** Visible label, wired to the amount input. */
label?: string;
/** Quick-fill chips as percentages of balance; Max is always appended. */
quickPercents?: number[];
/** Fiat readout currency code. */
fiatCurrency?: string;
/** Form field name; submits `<name>` and `<name>Token`. */
name?: string;
disabled?: boolean;
/** Disables the fiat digit roll. */
static?: boolean;
}
/**
* The treasury-grade amount field: a large token-denominated input, a token
* select whose swap re-prices everything, a live fiat readout that rolls
* odometer-style, quick percent fills, and balance validation that turns
* the field over politely rather than blocking input. Raw digits while
* focused; grouped formatting on blur.
*/
export function TokenAmountInput({
tokens,
value: valueProp,
defaultValue = 0,
onValueChange,
token: tokenProp,
defaultToken,
onTokenChange,
label = "Amount",
quickPercents = [25, 50],
fiatCurrency = "USD",
name,
disabled = false,
static: isStatic = false,
className,
...props
}: TokenAmountInputProps) {
const inputId = React.useId();
const messageId = React.useId();
const [uncontrolledValue, setUncontrolledValue] = React.useState(
Math.max(0, defaultValue),
);
const amount = valueProp ?? uncontrolledValue;
const [uncontrolledToken, setUncontrolledToken] = React.useState(
defaultToken ?? tokens[0]?.symbol ?? "",
);
const symbol = tokenProp ?? uncontrolledToken;
const active = tokens.find((t) => t.symbol === symbol) ?? tokens[0];
const decimals = active?.decimals ?? 2;
const [focused, setFocused] = React.useState(false);
const [draft, setDraft] = React.useState("");
const setAmount = React.useCallback(
(next: number) => {
const clamped = Math.max(0, next);
if (valueProp === undefined) setUncontrolledValue(clamped);
onValueChange?.(clamped);
},
[valueProp, onValueChange],
);
const changeToken = (next: string) => {
if (next === symbol) return;
if (tokenProp === undefined) setUncontrolledToken(next);
onTokenChange?.(next);
};
const commitDraft = () => {
const parsed = Number.parseFloat(draft.replace(/,/g, ""));
setAmount(Number.isNaN(parsed) ? 0 : parsed);
setFocused(false);
};
const insufficient = active ? amount > active.balance : false;
const fiat = active ? amount * active.usdPrice : 0;
const display = focused
? draft
: amount === 0
? ""
: formatToken(amount, decimals);
return (
<div
data-slot="token-amount-input"
className={cn(
"w-full rounded-xl border border-input bg-transparent p-3",
"transition-[border-color,box-shadow] duration-150 ease-out",
insufficient
? "border-destructive focus-within:ring-[3px] focus-within:ring-destructive/20"
: "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25 hover:not-focus-within:border-ring/60",
disabled && "pointer-events-none opacity-50",
className,
)}
{...props}
>
<div className="flex items-baseline justify-between gap-3">
<label
htmlFor={inputId}
className="text-xs font-medium text-muted-foreground"
>
{label}
</label>
{active && (
<span className="min-w-0 truncate text-xs text-muted-foreground tabular-nums">
Balance {formatToken(active.balance, decimals)} {active.symbol}
</span>
)}
</div>
<div className="mt-1.5 flex items-center gap-2">
<input
id={inputId}
type="text"
inputMode="decimal"
autoComplete="off"
placeholder="0.00"
disabled={disabled}
value={display}
aria-invalid={insufficient || undefined}
aria-describedby={insufficient ? messageId : undefined}
onFocus={() => {
setDraft(amount === 0 ? "" : String(amount));
setFocused(true);
}}
onBlur={commitDraft}
onChange={(event) => {
const clean = event.target.value.replace(/[^0-9.]/g, "");
setDraft(clean);
const parsed = Number.parseFloat(clean);
setAmount(Number.isNaN(parsed) ? 0 : parsed);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
}}
className={cn(
"h-9 min-w-0 flex-1 bg-transparent text-2xl font-semibold tracking-tight text-foreground tabular-nums outline-none",
"placeholder:text-muted-foreground/50",
)}
/>
<SelectPrimitive.Root
value={active?.symbol ?? ""}
onValueChange={changeToken}
disabled={disabled}
>
<SelectPrimitive.Trigger
aria-label="Token"
className={cn(
"flex h-8 shrink-0 items-center gap-1.5 rounded-full bg-secondary pr-2 pl-2.5 text-sm font-medium text-secondary-foreground select-none",
"transition-[background-color,scale] duration-150 ease-out",
"hover:bg-accent active:scale-[0.97]",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
{active?.icon ?? (
<span
aria-hidden
className="size-3.5 rounded-full"
style={{ background: active?.color ?? "var(--color-primary)" }}
/>
)}
<SelectPrimitive.Value />
<ChevronDown aria-hidden className="size-3.5 opacity-60" />
</SelectPrimitive.Trigger>
<style href="paragon-token-amount-input" precedence="paragon">{`
@keyframes pg-token-select-in {
from { opacity: 0; scale: 0.97; translate: 0 -2px; }
}
@keyframes pg-token-select-out {
to { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
@keyframes pg-token-select-in { from { opacity: 0; } }
}
`}</style>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
position="popper"
sideOffset={4}
align="end"
className={cn(
"z-50 min-w-52 origin-(--radix-select-content-transform-origin) rounded-lg bg-popover p-1 text-popover-foreground shadow-overlay",
"data-[state=open]:animate-[pg-token-select-in_150ms_var(--ease-out)]",
"data-[state=closed]:animate-[pg-token-select-out_75ms_var(--ease-exit)_forwards]",
)}
>
<SelectPrimitive.Viewport>
{tokens.map((t) => (
<SelectPrimitive.Item
key={t.symbol}
value={t.symbol}
className={cn(
"flex cursor-default items-center gap-2.5 rounded-md px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
)}
>
{t.icon ?? (
<span
aria-hidden
className="size-4 shrink-0 rounded-full"
style={{ background: t.color ?? "var(--color-primary)" }}
/>
)}
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
<SelectPrimitive.ItemText>
<span className="font-medium">{t.symbol}</span>
</SelectPrimitive.ItemText>
<span className="truncate text-xs text-muted-foreground">
{t.name}
</span>
</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{formatToken(t.balance, t.decimals ?? 2)}
</span>
<SelectPrimitive.ItemIndicator>
<Check aria-hidden className="size-3.5" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
</div>
<div className="mt-1.5 flex items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-1 text-xs text-muted-foreground tabular-nums">
≈
<DigitRoll
value={fiat}
static={isStatic}
formatOptions={{
style: "currency",
currency: fiatCurrency,
maximumFractionDigits: 2,
minimumFractionDigits: 2,
}}
/>
</span>
<span className="flex shrink-0 items-center gap-1">
{[...quickPercents.map((pct) => ({ pct, label: `${pct}%` })), { pct: 100, label: "Max" }].map(
({ pct, label: chipLabel }) => (
<button
key={chipLabel}
type="button"
disabled={disabled || !active}
onClick={() => {
if (!active) return;
const next = (active.balance * pct) / 100;
setAmount(Number(next.toFixed(decimals)));
setDraft(String(Number(next.toFixed(decimals))));
}}
className={cn(
"pressable relative h-6 rounded-full bg-secondary px-2 text-[11px] font-medium text-secondary-foreground select-none",
"transition-[background-color,color,scale] duration-150 ease-out",
"hover:bg-accent hover:text-accent-foreground",
"outline-none focus-visible:ring-2 focus-visible:ring-ring",
"after:absolute after:inset-x-0 after:top-1/2 after:h-10 after:-translate-y-1/2",
)}
>
{chipLabel}
</button>
),
)}
</span>
</div>
{insufficient && (
<p
id={messageId}
role="alert"
className="mt-1.5 text-xs text-destructive"
style={{
animation: "pg-token-message-in 200ms var(--ease-out) both",
}}
>
Exceeds available balance
<style href="paragon-token-amount-input-msg" precedence="paragon">{`
@keyframes pg-token-message-in {
from { opacity: 0; translate: 0 -2px; filter: blur(2px); }
}
@media (prefers-reduced-motion: reduce) {
@keyframes pg-token-message-in { from { opacity: 0; } }
}
`}</style>
</p>
)}
{name && (
<>
<input type="hidden" name={name} value={amount} />
<input type="hidden" name={`${name}Token`} value={active?.symbol ?? ""} />
</>
)}
</div>
);
}