Token Amount Input
Inputs & Forms

Token Amount Input

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.

Install

npx shadcn@latest add @paragon/token-amount-input

Also installs: digit-roll

token-amount-input.tsx

"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>
  );
}