shadimage
onchain-ui
uiasset-inputsource: ok · hit

Asset Input

Labeled amount field for swaps, sells, and deposits, with slots for token selection, supporting text, balances, and actions. No wallet provider required.

npx shadcn add "https://onchain-ui.dev/r/asset-input.json"
free

Interaction

dropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

inputfield

Files2 · source cached 10/15/2026, 7:00:22 AM

registry/onchain-ui/asset-input.tsx
"use client";

import { useId, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { AmountInput, type AmountInputProps } from "./amount-input";
import { FieldSet, FieldLabel, FieldDescription, FieldError } from "@/components/ui/field";

export interface AssetInputProps extends Omit<AmountInputProps, "className"> {
  /** Visible label connected to the amount input. */
  label: ReactNode;
  /** Token identity or your app's picker trigger. */
  asset?: ReactNode;
  /** Supporting text, such as a fiat estimate. Connected via aria-describedby. */
  description?: ReactNode;
  /** Validation message. Marks the input invalid and connects the message. */
  error?: ReactNode;
  /** Balance, Max actions, or other app-owned supporting content. */
  footer?: ReactNode;
  /** Applied to the field container. */
  className?: string;
  /** Applied to the amount input. */
  inputClassName?: string;
}

export function AssetInput({
  label,
  asset,
  description,
  error,
  footer,
  className,
  inputClassName,
  id,
  disabled,
  "aria-describedby": describedBy,
  "aria-invalid": ariaInvalid,
  ...props
}: AssetInputProps) {
  const generatedId = useId();
  const inputId = id ?? generatedId;
  const hasDescription = description != null && description !== false;
  const hasError = error != null && error !== false && error !== "";
  const invalid = hasError || ariaInvalid;
  const descriptionIds = [
    describedBy,
    hasDescription ? `${inputId}-description` : undefined,
    hasError ? `${inputId}-error` : undefined,
  ].filter(Boolean).join(" ") || undefined;

  return (
    <FieldSet
      disabled={disabled}
      data-slot="asset-input"
      data-invalid={invalid || undefined}
      className={cn(
        "m-0 grid min-w-0 gap-3 rounded-xl border border-input bg-muted/30 p-4",
        "focus-within:border-ring data-[invalid=true]:border-destructive disabled:opacity-50",
        className
      )}
    >
      <FieldLabel htmlFor={inputId} className="text-sm font-medium text-muted-foreground">
        {label}
      </FieldLabel>
      <div className="flex min-w-0 items-center gap-3">
        {asset != null && <div className="min-w-0 max-w-[50%] shrink-0">{asset}</div>}
        <AmountInput
          {...props}
          id={inputId}
          disabled={disabled}
          aria-describedby={descriptionIds}
          aria-invalid={invalid}
          className={cn(
            "h-auto flex-1 rounded-sm border-0 bg-transparent px-0 py-1 text-right text-3xl tracking-tight shadow-none focus-visible:ring-2 disabled:opacity-100 md:text-3xl dark:bg-transparent",
            inputClassName
          )}
        />
      </div>
      {hasDescription && (
        <FieldDescription id={`${inputId}-description`}>
          {description}
        </FieldDescription>
      )}
      {footer != null && <div className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">{footer}</div>}
      {hasError && (
        <FieldError id={`${inputId}-error`}>
          {error}
        </FieldError>
      )}
    </FieldSet>
  );
}
registry/onchain-ui/amount-input.tsx
"use client";

import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";

export interface AmountInputProps
  extends Omit<
    ComponentProps<"input">,
    "type" | "inputMode" | "value" | "defaultValue" | "onChange" | "min" | "max" | "step"
  > {
  /** Unformatted decimal string. Empty and incomplete values are valid while editing. */
  value: string;
  /** Receives accepted edits without numeric conversion or rounding. */
  onValueChange?: (value: string) => void;
  /** Maximum fractional digits. Omit for unrestricted precision; 0 allows integers only. */
  decimals?: number;
}

/** Checks editing syntax, not whether an amount is ready for a transaction. */
export function isAmountInputValue(value: string, decimals?: number): boolean {
  if (decimals !== undefined && (!Number.isInteger(decimals) || decimals < 0)) {
    return false;
  }
  if (!/^[0-9]*(\.[0-9]*)?$/.test(value)) return false;
  const point = value.indexOf(".");
  return point === -1 || decimals === undefined ||
    (decimals > 0 && value.length - point - 1 <= decimals);
}

export function AmountInput({
  value,
  onValueChange,
  decimals,
  readOnly = !onValueChange,
  disabled,
  className,
  placeholder = "0",
  ...props
}: AmountInputProps) {
  return (
    <Input
      autoComplete="off"
      spellCheck={false}
      size={1}
      {...props}
      data-slot="amount-input"
      type="text"
      inputMode="decimal"
      value={value}
      placeholder={placeholder}
      readOnly={readOnly}
      disabled={disabled}
      onChange={(event) => {
        const next = event.target.value;
        if (!disabled && !readOnly && isAmountInputValue(next, decimals)) {
          onValueChange?.(next);
        }
      }}
      className={cn(
        "font-mono tabular-nums",
        className
      )}
    />
  );
}

More from onchain-ui

Browse all components in this library →