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 · lightRegistry 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
)}
/>
);
}