Skip to content

Token Amount Input

Balance, percentage chips, USD value and a shake when over balance.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/token-amount-input.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge viem
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/color.ts
    export type Vec3 = [r: number, g: number, b: number];
    
    /**
     * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels.
     * Anything else falls back to black, so a typo never crashes a shader.
     */
    export function hexToRgb(hex: string): Vec3 {
      let value = hex.trim().replace(/^#/, "");
      if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join("");
      if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0];
      const n = Number.parseInt(value.slice(0, 6), 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }
    
    /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function hexToVec3(hex: string): Vec3 {
      const [r, g, b] = hexToRgb(hex);
      return [r / 255, g / 255, b / 255];
    }
    
    /**
     * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as
     * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables
     * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result
     * instead of calling this every frame. Browser only; unreadable values fall back to black.
     */
    export function cssColorToRgb(color: string, element: Element): Vec3 {
      const value = color.trim();
      if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value);
      // Children of a canvas still get computed styles, but a regular parent is the safer host.
      const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element;
      const probe = document.createElement("span");
      probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden";
      probe.style.color = value;
      host.appendChild(probe);
      const computed = getComputedStyle(probe).color;
      probe.remove();
      // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes.
      const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
      if (!ctx) return [0, 0, 0];
      ctx.fillStyle = "#000";
      ctx.fillStyle = computed;
      ctx.fillRect(0, 0, 1, 1);
      const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
      return [r, g, b];
    }
    
    /**
     * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or
     * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme.
     * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe.
     */
    export function observeTheme(onChange: () => void): () => void {
      const observer = new MutationObserver(onChange);
      const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
      observer.observe(document.documentElement, options);
      if (document.body) observer.observe(document.body, options);
      const scheme = window.matchMedia("(prefers-color-scheme: dark)");
      scheme.addEventListener("change", onChange);
      return () => {
        observer.disconnect();
        scheme.removeEventListener("change", onChange);
      };
    }
    
    /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function cssColorToVec3(color: string, element: Element): Vec3 {
      const [r, g, b] = cssColorToRgb(color, element);
      return [r / 255, g / 255, b / 255];
    }
    
    const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
    const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
    
    /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */
    export function rgbToOklch([r, g, b]: Vec3): Vec3 {
      const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255));
      const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
      const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
      const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
      const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
      const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
      const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
      return [L, Math.hypot(A, B), Math.atan2(B, A)];
    }
    
    /** OKLCH back to 0–255 sRGB, clamped into gamut. */
    export function oklchToRgb([L, C, h]: Vec3): Vec3 {
      const A = C * Math.cos(h);
      const B = C * Math.sin(h);
      const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
      const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
      const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
      const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
      return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3;
    }
    
    /**
     * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep
     * their chroma instead of turning muddy the way an RGB mix does.
     */
    export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 {
      const [L1, C1, h1] = rgbToOklch(a);
      const [L2, C2, h2] = rgbToOklch(b);
      // A near-gray end has no meaningful hue: borrow the other end's.
      const ha = C1 < 0.02 ? h2 : h1;
      const hb = C2 < 0.02 ? h1 : h2;
      let dh = hb - ha;
      if (dh > Math.PI) dh -= Math.PI * 2;
      if (dh < -Math.PI) dh += Math.PI * 2;
      return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]);
    }
    lib/web3/format.ts
    import { formatUnits, getAddress, isAddress, parseUnits } from "viem";
    
    /**
     * Formatting for on-chain values. Amounts are always `bigint` plus `decimals`: nothing here
     * converts a balance to a float, so large or precise values never lose digits.
     */
    
    /** Decimals used for USD values and prices across the web3 components (like Chainlink feeds). */
    export const USD_DECIMALS = 8;
    
    const COMPACT_UNITS = [
      { suffix: "T", exponent: 12 },
      { suffix: "B", exponent: 9 },
      { suffix: "M", exponent: 6 },
      { suffix: "K", exponent: 3 },
    ] as const;
    
    function groupThousands(integer: string) {
      return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
    }
    
    function compact(value: bigint, decimals: number): string | null {
      const base = BigInt(10) ** BigInt(decimals);
      for (const { suffix, exponent } of COMPACT_UNITS) {
        const unit = base * BigInt(10) ** BigInt(exponent);
        if (value < unit) continue;
        const tenths = (value * BigInt(10)) / unit;
        const whole = tenths / BigInt(10);
        const fraction = tenths % BigInt(10);
        return `${groupThousands(whole.toString())}${fraction && whole < BigInt(100) ? `.${fraction}` : ""}${suffix}`;
      }
      return null;
    }
    
    export interface FormatAmountOptions {
      /** Most fraction digits shown; extra digits are cut, never rounded up. @default 4 */
      maxFractionDigits?: number;
      /** Fraction digits always shown (padded with zeros). @default 0 */
      minFractionDigits?: number;
      /** Abbreviate values of 1,000 and up: 1.2K, 3.4M, 5B. @default false */
      compact?: boolean;
      /** Prefix positive values with "+". @default false */
      signed?: boolean;
    }
    
    /**
     * Formats a token amount: `formatAmount(parseUnits("1.23456789", 18), 18)` → "1.2345".
     * Digits past `maxFractionDigits` are truncated so a balance is never overstated, and a tiny
     * non-zero amount shows as "<0.0001" instead of "0".
     */
    export function formatAmount(value: bigint, decimals: number, options: FormatAmountOptions = {}) {
      const { maxFractionDigits = 4, minFractionDigits = 0, compact: useCompact = false, signed = false } = options;
      const negative = value < BigInt(0);
      const abs = negative ? -value : value;
      const sign = negative ? "-" : signed && abs > BigInt(0) ? "+" : "";
    
      const short = useCompact ? compact(abs, decimals) : null;
      if (short) return sign + short;
    
      const [integer, fraction = ""] = formatUnits(abs, decimals).split(".");
      let digits = fraction.slice(0, maxFractionDigits).replace(/0+$/, "");
      if (digits.length < minFractionDigits) digits = digits.padEnd(minFractionDigits, "0");
    
      if (abs > BigInt(0) && integer === "0" && !/[1-9]/.test(digits)) {
        return `${sign}<0.${"0".repeat(Math.max(0, maxFractionDigits - 1))}1`;
      }
      return `${sign}${groupThousands(integer)}${digits ? `.${digits}` : ""}`;
    }
    
    /** USD value of a token amount, as a bigint with `USD_DECIMALS` decimals. `price` uses the same decimals. */
    export function toUsd(amount: bigint, tokenDecimals: number, price: bigint) {
      return (amount * price) / BigInt(10) ** BigInt(tokenDecimals);
    }
    
    /** Formats a USD bigint (default `USD_DECIMALS`): "$1,234.56", or "$1.2M" with `compact`. */
    export function formatUsd(value: bigint, decimals = USD_DECIMALS, options: Omit<FormatAmountOptions, "maxFractionDigits"> = {}) {
      const { compact: useCompact = false, signed = false } = options;
      const negative = value < BigInt(0);
      const abs = negative ? -value : value;
      const sign = negative ? "-" : signed && abs > BigInt(0) ? "+" : "";
      // Under a cent, keep up to 6 significant fraction digits so token prices like $0.000042 stay readable.
      const tiny = abs > BigInt(0) && abs < BigInt(10) ** BigInt(Math.max(0, decimals - 2));
      const body = formatAmount(abs, decimals, {
        compact: useCompact,
        maxFractionDigits: tiny ? Math.min(decimals, 8) : 2,
        minFractionDigits: tiny ? 0 : 2,
      });
      return `${sign}$${body}`;
    }
    
    /** Formats a ratio as a percentage: `formatPercent(0.0123)` → "1.23%". */
    export function formatPercent(ratio: number, options: { fractionDigits?: number; signed?: boolean } = {}) {
      const { fractionDigits = 2, signed = false } = options;
      if (!Number.isFinite(ratio)) return "—";
      const text = `${(Math.abs(ratio) * 100).toFixed(fractionDigits)}%`;
      if (ratio < 0) return `-${text}`;
      return signed && ratio > 0 ? `+${text}` : text;
    }
    
    /** Formats basis points (1 bp = 0.01%): `formatBps(BigInt(50))` → "0.5%". */
    export function formatBps(bps: bigint | number) {
      const value = BigInt(bps);
      const abs = value < BigInt(0) ? -value : value;
      const fraction = abs % BigInt(100);
      return `${value < BigInt(0) ? "-" : ""}${abs / BigInt(100)}${fraction ? `.${fraction.toString().padStart(2, "0").replace(/0$/, "")}` : ""}%`;
    }
    
    /** Compact display for plain counts and non-token numbers: 1200 → "1.2K". */
    export function compactNumber(value: number | bigint) {
      return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(value);
    }
    
    /** EIP-55 checksummed address, or the input unchanged when it isn't a valid address. */
    export function checksumAddress(address: string) {
      return isAddress(address, { strict: false }) ? getAddress(address) : address;
    }
    
    /** "0x1234…abcd": checksummed, with `start` characters (including 0x) and `end` characters kept. */
    export function truncateAddress(address: string, { start = 6, end = 4 }: { start?: number; end?: number } = {}) {
      const value = checksumAddress(address);
      if (value.length <= start + end + 1) return value;
      return `${value.slice(0, start)}…${value.slice(-end)}`;
    }
    
    /**
     * Parses user input into a bigint amount. Accepts digits with one "." (commas are ignored),
     * cuts fraction digits past `decimals`, and returns null for anything that isn't a number.
     */
    export function parseAmount(input: string, decimals: number): bigint | null {
      const text = input.replace(/,/g, "").trim();
      if (!/^\d*\.?\d*$/.test(text) || text === "" || text === ".") return null;
      const [integer, fraction = ""] = text.split(".");
      return parseUnits(`${integer || "0"}.${fraction.slice(0, decimals) || "0"}`, decimals);
    }
    
    /** A percentage of an amount, in whole percent: `percentOf(balance, 25)`. Rounds down. */
    export function percentOf(amount: bigint, percent: number) {
      return (amount * BigInt(Math.round(percent * 100))) / BigInt(10_000);
    }
    lib/web3/icons.tsx
    "use client";
    
    import { useId, type ComponentProps } from "react";
    import { hexToRgb } from "@/lib/color";
    import { cn } from "@/lib/utils";
    
    /**
     * Generated avatars for tokens, chains and accounts. No third-party logos are bundled:
     * pass `iconUrl` (tokens, chains) or `src` (accounts) to show your own artwork.
     */
    
    const PALETTE = ["#6366F1", "#2563EB", "#0EA5E9", "#14B8A6", "#16A34A", "#F59E0B", "#EF4444", "#EC4899", "#8B5CF6", "#64748B"];
    
    function hashString(value: string) {
      let hash = 0x811c9dc5;
      for (const char of value) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193);
      return hash >>> 0;
    }
    
    /** Text color with enough contrast on a fill (relative luminance threshold). */
    function inkFor(fill: string) {
      const [r, g, b] = hexToRgb(fill).map((c) => {
        const s = c / 255;
        return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
      });
      return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.45 ? "#09090B" : "#FFFFFF";
    }
    
    function Monogram({ text, fill, size, shape, className }: { text: string; fill: string; size: number; shape: "circle" | "squircle"; className?: string }) {
      const letters = text.slice(0, 4);
      const fontSize = letters.length <= 2 ? 13 : letters.length === 3 ? 10 : 8.5;
      return (
        <svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" className={cn("shrink-0", className)}>
          {shape === "circle" ? <circle cx="16" cy="16" r="16" fill={fill} /> : <rect width="32" height="32" rx="9" fill={fill} />}
          <text
            x="16"
            y="16"
            dy="0.35em"
            textAnchor="middle"
            fontSize={fontSize}
            fontWeight={700}
            fill={inkFor(fill)}
            style={{ fontFamily: "inherit", letterSpacing: letters.length > 2 ? "-0.02em" : undefined }}
          >
            {letters}
          </text>
        </svg>
      );
    }
    
    function ImageIcon({ src, size, rounded, className }: { src: string; size: number; rounded: string; className?: string }) {
      // eslint-disable-next-line @next/next/no-img-element -- consumers pass arbitrary logo URLs
      return <img src={src} alt="" width={size} height={size} className={cn("shrink-0 object-cover", rounded, className)} />;
    }
    
    export interface TokenIconProps {
      token: { symbol: string; iconUrl?: string; color?: string };
      /** Pixel size. @default 24 */
      size?: number;
      className?: string;
    }
    
    /** A token's logo, or a colored circle with its symbol. Decorative: pair it with visible text. */
    export function TokenIcon({ token, size = 24, className }: TokenIconProps) {
      if (token.iconUrl) return <ImageIcon src={token.iconUrl} size={size} rounded="rounded-full" className={className} />;
      const fill = token.color ?? PALETTE[hashString(token.symbol) % PALETTE.length];
      return <Monogram text={token.symbol} fill={fill} size={size} shape="circle" className={className} />;
    }
    
    export interface ChainIconProps {
      chain: { name: string; shortName?: string; iconUrl?: string; color?: string };
      /** Pixel size. @default 20 */
      size?: number;
      className?: string;
    }
    
    /** A chain's logo, or a rounded square with its initial. Decorative: pair it with visible text. */
    export function ChainIcon({ chain, size = 20, className }: ChainIconProps) {
      if (chain.iconUrl) return <ImageIcon src={chain.iconUrl} size={size} rounded="rounded-[28%]" className={className} />;
      const fill = chain.color ?? PALETTE[hashString(chain.name) % PALETTE.length];
      return <Monogram text={(chain.shortName ?? chain.name).charAt(0)} fill={fill} size={size} shape="squircle" className={className} />;
    }
    
    export interface WalletIconProps {
      wallet: { name: string; iconUrl?: string; color?: string };
      /** Pixel size. @default 32 */
      size?: number;
      className?: string;
    }
    
    /** A wallet's logo, or a rounded square with its initial. Decorative: pair it with visible text. */
    export function WalletIcon({ wallet, size = 32, className }: WalletIconProps) {
      if (wallet.iconUrl) return <ImageIcon src={wallet.iconUrl} size={size} rounded="rounded-[28%]" className={className} />;
      const fill = wallet.color ?? PALETTE[hashString(wallet.name) % PALETTE.length];
      return <Monogram text={wallet.name.charAt(0)} fill={fill} size={size} shape="squircle" className={className} />;
    }
    
    export interface AddressAvatarProps extends Omit<ComponentProps<"svg">, "children"> {
      address: string;
      /** Pixel size. @default 24 */
      size?: number;
    }
    
    /** A deterministic gradient avatar for an address: same address, same avatar, on every device. */
    export function AddressAvatar({ address, size = 24, className, ...props }: AddressAvatarProps) {
      const id = useId();
      const hex = address.toLowerCase().replace(/^0x/, "").padEnd(40, "0");
      const byte = (i: number) => Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) || 0;
      const hue = Math.round((byte(0) / 255) * 360);
      const hue2 = (hue + 40 + Math.round((byte(1) / 255) * 120)) % 360;
      const angle = Math.round((byte(2) / 255) * 360);
      return (
        <svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" className={cn("shrink-0 rounded-full", className)} {...props}>
          <defs>
            <linearGradient id={`${id}g`} gradientTransform={`rotate(${angle} 0.5 0.5)`}>
              <stop offset="0" stopColor={`hsl(${hue} 85% 64%)`} />
              <stop offset="1" stopColor={`hsl(${hue2} 80% 52%)`} />
            </linearGradient>
            <clipPath id={`${id}c`}>
              <circle cx="16" cy="16" r="16" />
            </clipPath>
          </defs>
          <g clipPath={`url(#${id}c)`}>
            <rect width="32" height="32" fill={`url(#${id}g)`} />
            <circle cx={6 + (byte(3) % 20)} cy={6 + (byte(4) % 20)} r={6 + (byte(5) % 6)} fill="#fff" opacity="0.32" />
            <circle cx={6 + (byte(6) % 20)} cy={6 + (byte(7) % 20)} r={3 + (byte(8) % 5)} fill="#fff" opacity="0.22" />
          </g>
        </svg>
      );
    }
    lib/web3/types.ts
    /** Shapes the web3 components accept. Your own token and chain objects only need these fields. */
    
    export interface Token {
      symbol: string;
      name?: string;
      /** Token decimals: 18 for ETH, 6 for USDC. Amounts are bigint in these units. */
      decimals: number;
      address?: string;
      /** Logo URL. Without one, a generated avatar (colored circle and symbol) is shown. */
      iconUrl?: string;
      /** Avatar color for the generated icon. Picked from the symbol when omitted. */
      color?: string;
      /** USD price as a bigint with USD_DECIMALS (8) decimals. */
      price?: bigint;
    }
    
    export interface Chain {
      id: number;
      name: string;
      shortName?: string;
      iconUrl?: string;
      color?: string;
      /** Block explorer base URL without a trailing slash, e.g. https://etherscan.io */
      explorerUrl?: string;
      nativeSymbol?: string;
    }
    
    export interface WalletOption {
      id: string;
      name: string;
      /** Logo URL. Without one, a generated squircle with the wallet's initial is shown. */
      iconUrl?: string;
      color?: string;
      /** Shows a "Recent" badge. */
      recent?: boolean;
      /** The wallet is detected in this browser. */
      installed?: boolean;
      /** Connects by scanning a QR code with a phone instead of a browser extension. */
      qr?: boolean;
      /** Short helper line under the name. */
      description?: string;
    }
    
    /** Lifecycle of anything the user signs: every state gets its own visual. */
    export type TxStatus = "idle" | "awaiting-signature" | "pending" | "confirmed" | "failed";
    
    /** Explorer URL for an address or transaction, or undefined without an explorer. */
    export function explorerLink(explorerUrl: string | undefined, kind: "address" | "tx", value: string) {
      return explorerUrl ? `${explorerUrl.replace(/\/$/, "")}/${kind}/${value}` : undefined;
    }
  3. Copy the source

    components/ui/token-amount-input/token-amount-input.tsx
    "use client";
    
    import { useEffect, useId, useState, type ComponentProps } from "react";
    import { useAnimate, useReducedMotion } from "motion/react";
    import { ChevronDown } from "lucide-react";
    import { formatUnits } from "viem";
    import { cn } from "@/lib/utils";
    import { formatAmount, formatUsd, parseAmount, percentOf, toUsd } from "@/lib/web3/format";
    import { TokenIcon } from "@/lib/web3/icons";
    import type { Token } from "@/lib/web3/types";
    
    export interface TokenAmountInputProps
      extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** The selected token. Its `price` powers the USD line. */
      token?: Token;
      /** Raw input text (controlled), e.g. "1.25". */
      value: string;
      /** Called with the cleaned text and the parsed amount (null when empty or invalid). */
      onValueChange?: (value: string, amount: bigint | null) => void;
      /** Wallet balance in the token's smallest unit. Enables the chips and the balance check. */
      balance?: bigint;
      /** Label above the field. @default "Amount" */
      label?: string;
      /** Percentage chips; 100 shows as "Max". Pass [] to hide them. @default [25, 50, 75, 100] */
      percentages?: number[];
      /** Called when the token button is pressed (open a token selector here). Without it the token is static. */
      onTokenClick?: () => void;
      /** Read-only output field, e.g. "You receive". @default false */
      readOnly?: boolean;
      /** Shimmer the value while it loads, e.g. while fetching a quote. @default false */
      loading?: boolean;
      /** Error text that overrides the built-in balance check. */
      error?: string;
      /** Disables the field and chips. @default false */
      disabled?: boolean;
    }
    
    const sanitize = (text: string, decimals: number) => {
      const cleaned = text.replace(/,/g, ".").replace(/[^\d.]/g, "");
      const [integer, ...rest] = cleaned.split(".");
      if (rest.length === 0) return integer.replace(/^0+(?=\d)/, "");
      return `${integer.replace(/^0+(?=\d)/, "") || "0"}.${rest.join("").slice(0, decimals)}`;
    };
    
    export function TokenAmountInput({
      token,
      value,
      onValueChange,
      balance,
      label = "Amount",
      percentages = [25, 50, 75, 100],
      onTokenClick,
      readOnly = false,
      loading = false,
      error,
      disabled = false,
      className,
      ...props
    }: TokenAmountInputProps) {
      const reduceMotion = useReducedMotion() ?? false;
      const id = useId();
      const decimals = token?.decimals ?? 18;
      const amount = parseAmount(value, decimals);
      const insufficient = !readOnly && balance !== undefined && amount !== null && amount > balance;
      const message = error ?? (insufficient ? `Insufficient ${token?.symbol ?? ""} balance`.trim() : undefined);
      const usd = amount !== null && token?.price !== undefined ? toUsd(amount, decimals, token.price) : undefined;
    
      // Count each transition into the error state; the count keys the shake.
      const [wasInvalid, setWasInvalid] = useState(Boolean(message));
      const [shakes, setShakes] = useState(0);
      if (Boolean(message) !== wasInvalid) {
        setWasInvalid(Boolean(message));
        if (message) setShakes(shakes + 1);
      }
      const [scope, animate] = useAnimate<HTMLDivElement>();
      useEffect(() => {
        if (shakes === 0 || reduceMotion || !scope.current) return;
        animate(scope.current, { x: [0, -6, 6, -4, 4, 0] }, { duration: 0.4, ease: "easeInOut" });
      }, [shakes, reduceMotion, animate, scope]);
    
      const set = (text: string) => {
        const next = sanitize(text, decimals);
        onValueChange?.(next, parseAmount(next, decimals));
      };
    
      const tokenButton = token ? (
        <>
          <TokenIcon token={token} size={24} />
          <span className="font-semibold">
            {onTokenClick ? <span className="sr-only">Change token, </span> : null}
            {token.symbol}
            {onTokenClick ? <span className="sr-only"> selected</span> : null}
          </span>
        </>
      ) : (
        <span className="px-1 font-medium">Select token</span>
      );
    
      return (
        <div
          className={cn(
            "rounded-2xl border bg-surface p-4 transition-colors focus-within:bg-background",
            message ? "border-destructive/40" : "border-border focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/15",
            disabled && "opacity-60",
            className,
          )}
          {...props}
        >
          <div className="flex items-center justify-between gap-3">
            <label htmlFor={id} className="text-[13px] text-muted-foreground">
              {label}
            </label>
            {balance !== undefined && token ? (
              <span className="font-mono text-xs text-muted-foreground tabular-nums">
                Balance {formatAmount(balance, decimals)}
              </span>
            ) : null}
          </div>
          {/* A size container, so the amount steps down on a narrow row instead of hiding under the token button. */}
          <div className="@container mt-2 flex items-center gap-3">
            <div ref={scope} className="relative min-w-0 flex-1">
              <input
                id={id}
                value={value}
                onChange={(event) => set(event.target.value)}
                readOnly={readOnly}
                disabled={disabled}
                inputMode="decimal"
                autoComplete="off"
                spellCheck={false}
                placeholder="0"
                aria-invalid={message ? true : undefined}
                aria-describedby={message ? `${id}-error` : `${id}-usd`}
                aria-busy={loading || undefined}
                className={cn(
                  "w-full min-w-0 bg-transparent font-mono text-[clamp(20px,9.5cqi,28px)] leading-10 font-semibold text-ellipsis tracking-tight tabular-nums outline-none placeholder:text-muted-foreground/50",
                  message ? "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]" : "text-foreground",
                  loading && "text-transparent",
                  readOnly && "cursor-default",
                )}
              />
              {loading ? (
                <span
                  aria-hidden="true"
                  className="absolute top-1/2 left-0 h-7 w-32 -translate-y-1/2 animate-pulse rounded-md bg-muted motion-reduce:animate-none"
                />
              ) : null}
            </div>
            {onTokenClick ? (
              <button
                type="button"
                onClick={onTokenClick}
                disabled={disabled}
                className="inline-flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-full border border-border bg-background py-1 pr-2.5 pl-1.5 text-sm text-foreground shadow-xs outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
              >
                {tokenButton}
                <ChevronDown className="size-4 text-muted-foreground" aria-hidden="true" />
              </button>
            ) : (
              <span className="inline-flex h-10 shrink-0 items-center gap-2 rounded-full border border-border bg-background py-1 pr-3 pl-1.5 text-sm text-foreground">
                {tokenButton}
              </span>
            )}
          </div>
          <div className="mt-2 flex min-h-8 flex-wrap items-center justify-between gap-2">
            {message ? (
              <p id={`${id}-error`} role="alert" className="text-xs font-medium text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]">
                {message}
              </p>
            ) : (
              <p id={`${id}-usd`} className="font-mono text-xs text-muted-foreground tabular-nums">
                {usd !== undefined ? `≈ ${formatUsd(usd)}` : token?.price !== undefined ? "≈ $0.00" : ""}
              </p>
            )}
            {!readOnly && balance !== undefined && percentages.length > 0 ? (
              <div className="flex gap-1">
                {percentages.map((pct) => {
                  const target = pct >= 100 ? balance : percentOf(balance, pct);
                  const active = amount !== null && amount === target && target > BigInt(0);
                  return (
                    <button
                      key={pct}
                      type="button"
                      disabled={disabled || balance === BigInt(0)}
                      onClick={() => set(formatUnits(target, decimals))}
                      aria-pressed={active}
                      className={cn(
                        "relative h-7 cursor-pointer rounded-full border px-2.5 font-mono text-[11px] font-medium tabular-nums outline-none transition-colors after:absolute after:-inset-1 focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
                        active ? "border-foreground bg-foreground text-background" : "border-border bg-background text-muted-foreground hover:text-foreground",
                      )}
                    >
                      {pct >= 100 ? "Max" : `${pct}%`}
                    </button>
                  );
                })}
              </div>
            ) : null}
          </div>
        </div>
      );
    }

Usage

import { TokenAmountInput } from "@/components/ui/token-amount-input/token-amount-input";

const [value, setValue] = useState("");

<TokenAmountInput
  label="You pay"
  token={{ symbol: "ETH", decimals: 18, price: ethPrice }}
  balance={balance} // bigint in wei
  value={value}
  onValueChange={(text, amount) => setValue(text)} // amount: bigint | null
  onTokenClick={() => setSelectorOpen(true)}
/>

API reference

PropTypeDefaultDescription
tokenToken–The selected token. Its `price` powers the USD line.
value*string–Raw input text (controlled), e.g. "1.25".
onValueChange(value: string, amount: bigint | null) => void–Called with the cleaned text and the parsed amount (null when empty or invalid).
balancebigint–Wallet balance in the token's smallest unit. Enables the chips and the balance check.
labelstring"Amount"Label above the field.
percentagesnumber[][25, 50, 75, 100]Percentage chips; 100 shows as "Max". Pass [] to hide them.
onTokenClick() => void–Called when the token button is pressed (open a token selector here). Without it the token is static.
readOnlybooleanfalseRead-only output field, e.g. "You receive".
loadingbooleanfalseShimmer the value while it loads, e.g. while fetching a quote.
errorstring–Error text that overrides the built-in balance check.
disabledbooleanfalseDisables the field and chips.

Also accepts every native <div> attribute, including ref and className.

  • Input is sanitized as you type (digits and one decimal point, commas become points) and trimmed to the token's decimals. onValueChange gives you the parsed bigint, so you never parse floats.
  • Chips compute exact amounts with bigint math; Max uses the full balance with every decimal.

Accessibility

  • The visible label is tied to the input, which uses inputMode="decimal" for a numeric keypad on phones.
  • Going over your balance sets aria-invalid, links an alert with the error and shakes the field once. Reduced motion skips the shake.
  • Percentage chips are toggle buttons (aria-pressed) with 40px hit areas; the token button names the selected token.
  • While loading, the field is aria-busy and its value is replaced by a pulse that stops with reduced motion.