shadimage
onchain-ui
uiasset-rowsource: ok · hit

Asset Row

Displays a practical portfolio or watchlist row from token identity, network, amount, value, and change.

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

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

tooltip

Files7 · source cached 10/14/2026, 11:33:11 AM

registry/onchain-ui/asset-row.tsx
import { cn } from "@/lib/utils";
import {
  parseNumericValue,
  type NumericValue,
} from "@/lib/onchain/format";
import { NetworkLogo, type NetworkLogoProps } from "./network-logo";
import { TokenLogo, type TokenLogoProps } from "./token-logo";
import { TokenPrice, type TokenPriceProps } from "./token-price";
import type { ReactNode } from "react";

type AmountValue = NumericValue;

export interface AssetRowProps {
  /** Token symbol */
  symbol: string;
  /** Token name */
  name?: string | null;
  /** Token image URL */
  src?: string | null;
  /** Token contract address */
  address?: string | null;
  /** Whether the asset is the chain's native currency */
  isNative?: boolean;
  /** Token amount held by the user */
  amount?: AmountValue;
  /** Fiat value for the position */
  value?: TokenPriceProps["value"];
  /** Price or position change percentage */
  change?: TokenPriceProps["change"];
  /** Optional network chain id for the logo badge */
  chainId?: number | null;
  /** Optional network name for the logo badge */
  networkName?: string | null;
  /** Optional network image URL for the logo badge */
  networkSrc?: string | null;
  /** Secondary metadata shown beside the token name */
  subtitle?: ReactNode;
  /** Hide the right-side amount. Default: false */
  hideAmount?: boolean;
  /** Locale passed to Intl.NumberFormat */
  locale?: string;
  /** Maximum fraction digits for the token amount. Default: 4 */
  maximumFractionDigits?: number;
  /** Props passed to TokenLogo */
  logoProps?: Partial<TokenLogoProps>;
  /** Props passed to NetworkLogo */
  networkLogoProps?: Partial<NetworkLogoProps>;
  /** Props passed to TokenPrice */
  priceProps?: Partial<TokenPriceProps>;
  /** Applied to the root wrapper */
  className?: string;
  /** Applied to the token identity block */
  identityClassName?: string;
  /** Applied to the right-side value block */
  valueClassName?: string;
}

function formatAmount({
  locale,
  maximumFractionDigits,
  value,
}: {
  locale?: string;
  maximumFractionDigits: number;
  value: number;
}) {
  return new Intl.NumberFormat(locale, {
    maximumFractionDigits,
  }).format(value);
}

export function AssetRow({
  symbol,
  name,
  src,
  address,
  isNative = false,
  amount,
  value,
  change,
  chainId,
  networkName,
  networkSrc,
  subtitle,
  hideAmount = false,
  locale,
  maximumFractionDigits = 4,
  logoProps,
  networkLogoProps,
  priceProps,
  className,
  identityClassName,
  valueClassName,
}: AssetRowProps) {
  const parsedAmount = parseNumericValue(amount);
  const hasNetwork = Boolean(chainId || networkName || networkSrc);
  const hasValue = value !== null && value !== undefined && value !== "";
  const hasChange = typeof change === "number" && Number.isFinite(change);

  return (
    <div
      className={cn(
        "flex w-full min-w-0 items-center justify-between gap-4 rounded-lg px-1 py-3",
        className
      )}
    >
      <div
        className={cn(
          "flex min-w-0 flex-1 items-center gap-3",
          identityClassName
        )}
      >
        <span className="relative shrink-0">
          <TokenLogo
            address={address}
            chainId={chainId}
            isNative={isNative}
            name={name}
            size="xl"
            src={src}
            symbol={symbol}
            {...logoProps}
          />
          {hasNetwork && (
            <NetworkLogo
              chainId={chainId}
              className="absolute -bottom-1 -right-1 ring-2 ring-background"
              name={networkName}
              size="xs"
              src={networkSrc}
              {...networkLogoProps}
            />
          )}
        </span>

        <div className="min-w-0 flex-1">
          <div className="flex min-w-0 items-baseline gap-2">
            <p className="truncate text-base font-medium leading-tight">
              {name ?? symbol}
            </p>
            {subtitle && (
              <span className="truncate text-sm text-muted-foreground">
                {subtitle}
              </span>
            )}
          </div>
          {hasChange && (
            <TokenPrice
              {...priceProps}
              value={null}
              fallback={null}
              change={change}
              className="mt-1 gap-0"
              priceClassName="hidden"
              changeClassName={cn("text-sm", priceProps?.changeClassName)}
            />
          )}
        </div>
      </div>

      <div className={cn("grid shrink-0 gap-1 text-right", valueClassName)}>
        {hasValue && (
          <TokenPrice
            {...priceProps}
            value={value}
            change={null}
            className={cn("justify-end text-base", priceProps?.className)}
            priceClassName={cn("font-semibold", priceProps?.priceClassName)}
          />
        )}
        {!hideAmount && parsedAmount !== null && (
          <p className="font-mono text-sm text-muted-foreground tabular-nums">
            {formatAmount({ locale, maximumFractionDigits, value: parsedAmount })}{" "}
            {symbol}
          </p>
        )}
      </div>
    </div>
  );
}
registry/onchain-ui/token-logo.tsx
"use client";

import { useMemo, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { getTokenIcon } from "@/lib/onchain/crypto-icons";
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip";

const sizeClasses = {
  xs: "size-5 text-[9px]",
  sm: "size-6 text-[10px]",
  md: "size-8 text-xs",
  lg: "size-10 text-sm",
  xl: "size-12 text-base",
} as const;

// Pixel sizes for layouts that size marks in px rather than steps. The font
// tracks the named sizes (~3/8 of the diameter) with a floor for legibility.
function getPixelSizeStyle(size: number) {
  return { width: size, height: size, fontSize: Math.max(8, Math.round(size * 0.375)) };
}

export interface TokenLogoProps {
  /** Token image URL. Always wins over inferred icons */
  src?: string | null;
  /** Token symbol, used for alt text and fallback initials */
  symbol?: string | null;
  /** Token name, used for alt text */
  name?: string | null;
  /** Token contract address, used for icon inference and as a fallback label */
  address?: string | null;
  /** EVM chain id, used with address to infer an icon via the crypto-icons adapter */
  chainId?: number | null;
  /** Use the native-currency icon rather than the wrapped token at `address` */
  isNative?: boolean;
  /** Named size, or a diameter in pixels such as 28. Default: md */
  size?: keyof typeof sizeClasses | number;
  /** Custom fallback content when the image is missing or fails */
  fallback?: ReactNode;
  /** Show an animated tooltip. Default: false */
  showTooltip?: boolean;
  /** Tooltip content. Defaults to name, symbol, then address. */
  tooltipContent?: ReactNode;
  /** Applied to the root wrapper */
  className?: string;
  /** Applied to the image */
  imageClassName?: string;
  /** Applied to the fallback */
  fallbackClassName?: string;
}

function getFallbackText({
  address,
  name,
  symbol,
}: Pick<TokenLogoProps, "address" | "name" | "symbol">) {
  if (symbol) return symbol.slice(0, 4).toUpperCase();
  if (name) return name.slice(0, 2).toUpperCase();
  if (address) return address.slice(2, 4).toUpperCase();
  return "?";
}

export function TokenLogo({
  src,
  symbol,
  name,
  address,
  chainId,
  isNative = false,
  size = "md",
  fallback,
  showTooltip = false,
  tooltipContent,
  className,
  imageClassName,
  fallbackClassName,
}: TokenLogoProps) {
  const [failedSrc, setFailedSrc] = useState<string | null>(null);

  // Explicit src wins; otherwise ask the crypto-icons adapter.
  const inferredIcon = useMemo(
    () =>
      src ? null : getTokenIcon({ chainId, address, isNative, symbol }),
    [address, chainId, isNative, src, symbol]
  );
  const resolvedSrc =
    src ?? (typeof inferredIcon === "string" ? inferredIcon : null);
  const inferredNode =
    inferredIcon != null && typeof inferredIcon !== "string"
      ? inferredIcon
      : null;
  const shouldShowImage = Boolean(resolvedSrc) && failedSrc !== resolvedSrc;

  const alt = useMemo(() => {
    if (name && symbol) return `${name} (${symbol})`;
    return name ?? symbol ?? address ?? "Token";
  }, [address, name, symbol]);

  const tooltip = tooltipContent ?? name ?? symbol ?? address;

  const logo = (
    <span
      className={cn(
        "relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border bg-muted font-medium text-muted-foreground",
        typeof size === "number" ? null : sizeClasses[size],
        className
      )}
      style={typeof size === "number" ? getPixelSizeStyle(size) : undefined}
    >
      {shouldShowImage ? (
        <img
          src={resolvedSrc ?? undefined}
          alt={alt}
          className={cn("size-full object-cover", imageClassName)}
          loading="lazy"
          decoding="async"
          referrerPolicy="no-referrer"
          onError={() => setFailedSrc(resolvedSrc)}
        />
      ) : inferredNode ? (
        inferredNode
      ) : (
        <span className={cn("select-none", fallbackClassName)}>
          {fallback ?? getFallbackText({ address, name, symbol })}
        </span>
      )}
    </span>
  );

  if (!showTooltip || tooltip == null) return logo;

  return (
    <Tooltip>
      <TooltipTrigger render={logo} />
      <TooltipContent>{tooltip}</TooltipContent>
    </Tooltip>
  );
}
registry/onchain-ui/network-logo.tsx
"use client";

import { useMemo, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { getNetworkIcon } from "@/lib/onchain/crypto-icons";
import { getNetworkMeta } from "@/lib/onchain/config";
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip";

const sizeClasses = {
  xs: "size-5 text-[8px]",
  sm: "size-6 text-[9px]",
  md: "size-8 text-[10px]",
  lg: "size-10 text-xs",
  xl: "size-12 text-sm",
} as const;

export interface NetworkLogoProps {
  /** EVM chain id */
  chainId?: number | null;
  /** Network image URL override */
  src?: string | null;
  /** Network name, used for alt text and fallback */
  name?: string | null;
  /** Short network symbol, used for fallback */
  symbol?: string | null;
  /** Visual size. Default: md */
  size?: keyof typeof sizeClasses;
  /** Custom fallback content for unsupported chains */
  fallback?: ReactNode;
  /** Show an animated tooltip. Default: false */
  showTooltip?: boolean;
  /** Tooltip content. Defaults to network name, symbol, then chain id */
  tooltipContent?: ReactNode;
  /** Applied to the root wrapper */
  className?: string;
  /** Applied to the image */
  imageClassName?: string;
  /** Applied to the fallback */
  fallbackClassName?: string;
}

// How many characters fit inside the circle at each size, measured rather than
// derived: the font and the circle do not grow in step, so the limit is not a
// constant ratio. Values leave ~20% slack so glyphs never touch the border.
const fallbackMaxChars = {
  xs: 3,
  sm: 3,
  md: 4,
  lg: 4,
  xl: 4,
} as const;

function getFallbackText({
  chainId,
  name,
  symbol,
  maxChars,
}: Pick<NetworkLogoProps, "chainId" | "name" | "symbol"> & {
  maxChars: number;
}) {
  if (symbol) return symbol.slice(0, Math.min(4, maxChars)).toUpperCase();
  if (name) return name.slice(0, 2).toUpperCase();
  // A chain id is only shown when it fits whole. Truncating one produces a
  // different, real-looking chain id, which is worse than admitting we do not
  // recognise the network — the full id stays in `alt` and the tooltip.
  if (chainId != null) {
    const id = String(chainId);
    if (id.length <= maxChars) return id;
  }
  return "?";
}

export function NetworkLogo({
  chainId,
  src,
  name,
  symbol,
  size = "md",
  fallback,
  showTooltip = false,
  tooltipContent,
  className,
  imageClassName,
  fallbackClassName,
}: NetworkLogoProps) {
  const [failedSrc, setFailedSrc] = useState<string | null>(null);
  const knownNetwork = getNetworkMeta(chainId);

  // Explicit src wins; otherwise ask the crypto-icons adapter.
  const adapterIcon = src ? null : getNetworkIcon(chainId);
  const resolvedSrc =
    src ?? (typeof adapterIcon === "string" ? adapterIcon : null);
  const knownIcon =
    adapterIcon != null && typeof adapterIcon !== "string"
      ? adapterIcon
      : null;
  const displayName = name ?? knownNetwork.name;
  const displaySymbol = symbol ?? knownNetwork.symbol;
  const shouldShowImage = Boolean(resolvedSrc) && failedSrc !== resolvedSrc;

  const alt = useMemo(() => {
    if (displayName && displaySymbol) return `${displayName} (${displaySymbol})`;
    return displayName ?? displaySymbol ?? String(chainId ?? "Network");
  }, [chainId, displayName, displaySymbol]);

  const tooltip = tooltipContent ?? displayName ?? displaySymbol ?? chainId;

  const logo = (
    <span
      className={cn(
        "relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border bg-muted font-semibold text-muted-foreground",
        sizeClasses[size],
        className
      )}
    >
      {shouldShowImage ? (
        <img
          src={resolvedSrc ?? undefined}
          alt={alt}
          className={cn("size-full object-cover", imageClassName)}
          loading="lazy"
          decoding="async"
          referrerPolicy="no-referrer"
          onError={() => setFailedSrc(resolvedSrc)}
        />
      ) : knownIcon && !fallback ? (
        knownIcon
      ) : (
        <span className={cn("select-none", fallbackClassName)}>
          {fallback ??
            getFallbackText({
              chainId,
              name: displayName,
              symbol: displaySymbol,
              maxChars: fallbackMaxChars[size],
            })}
        </span>
      )}
    </span>
  );

  if (!showTooltip || tooltip == null) return logo;

  return (
    <Tooltip>
      <TooltipTrigger render={logo} />
      <TooltipContent>{tooltip}</TooltipContent>
    </Tooltip>
  );
}
registry/onchain-ui/token-price.tsx
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
  parseNumericValue,
  type NumericValue,
} from "@/lib/onchain/format";
import type { ReactNode } from "react";

type PriceValue = NumericValue;
type Trend = "down" | "neutral" | "up";

// `positive` and `negative` are theme tokens, like `destructive`: the registry
// item adds `--positive` and `--negative` to your CSS for light and dark mode
// (keeping any values you already define), so you retheme gains and losses in
// your stylesheet rather than here.
const trendClasses = {
  down: "text-negative",
  neutral: "text-muted-foreground",
  up: "text-positive",
} as const;

export interface TokenPriceProps {
  /** Token price value */
  value: PriceValue;
  /** ISO 4217 currency code. Default: USD */
  currency?: string;
  /** Locale passed to Intl.NumberFormat */
  locale?: string;
  /** Use compact notation, e.g. $3.2K */
  compact?: boolean;
  /** Value shown when price is null, undefined, empty, or invalid. Default: -- */
  fallback?: ReactNode;
  /** Minimum fraction digits for the price */
  minimumFractionDigits?: number;
  /** Maximum fraction digits for the price */
  maximumFractionDigits?: number;
  /** 24h-style percentage change, e.g. -2.34 means -2.34% */
  change?: number | null;
  /** Override the derived change direction */
  trend?: Trend;
  /** Show trend icon before the change. Default: true */
  showChangeIcon?: boolean;
  /** Fraction digits for the change value. Default: 2 */
  changeFractionDigits?: number;
  /** Hide the plus sign for positive changes. Default: false */
  hidePositiveSign?: boolean;
  /** Applied to the root wrapper */
  className?: string;
  /** Applied to the formatted price */
  priceClassName?: string;
  /** Applied to the change badge */
  changeClassName?: string;
}

function getMaximumFractionDigits(value: number) {
  const abs = Math.abs(value);
  if (abs >= 1) return 2;
  if (abs >= 0.01) return 4;
  return 6;
}

function formatPrice({
  compact,
  currency,
  locale,
  maximumFractionDigits,
  minimumFractionDigits,
  value,
}: {
  compact: boolean;
  currency: string;
  locale?: string;
  maximumFractionDigits?: number;
  minimumFractionDigits?: number;
  value: number;
}) {
  const abs = Math.abs(value);
  const hasExplicitDigits =
    maximumFractionDigits !== undefined || minimumFractionDigits !== undefined;

  // Sub-cent prices round to $0.00 with fraction digits; keep the leading
  // significant figures instead, e.g. $0.00000041.
  if (!hasExplicitDigits && abs > 0 && abs < 0.01) {
    return new Intl.NumberFormat(locale, {
      style: "currency",
      currency,
      currencyDisplay: "narrowSymbol",
      notation: compact ? "compact" : "standard",
      maximumSignificantDigits: 2,
    }).format(value);
  }

  const resolvedMaximumFractionDigits =
    maximumFractionDigits ?? getMaximumFractionDigits(value);

  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
    currencyDisplay: "narrowSymbol",
    notation: compact ? "compact" : "standard",
    minimumFractionDigits,
    // Intl.NumberFormat throws a RangeError when min > max
    maximumFractionDigits: Math.max(
      resolvedMaximumFractionDigits,
      minimumFractionDigits ?? 0
    ),
  }).format(value);
}

function getTrend(change?: number | null): Trend {
  if (!change) return "neutral";
  return change > 0 ? "up" : "down";
}

function formatChange(
  change: number,
  fractionDigits: number,
  hidePositiveSign: boolean
) {
  const sign = change > 0 && !hidePositiveSign ? "+" : "";
  return `${sign}${change.toFixed(fractionDigits)}%`;
}

function TrendIcon({ trend }: { trend: Trend }) {
  if (trend === "up") return <ArrowUpIcon className="size-3" />;
  if (trend === "down") return <ArrowDownIcon className="size-3" />;
  return <MinusIcon className="size-3" />;
}

export function TokenPrice({
  value,
  currency = "USD",
  locale,
  compact = false,
  fallback = "--",
  minimumFractionDigits,
  maximumFractionDigits,
  change,
  trend,
  showChangeIcon = true,
  changeFractionDigits = 2,
  hidePositiveSign = false,
  className,
  priceClassName,
  changeClassName,
}: TokenPriceProps) {
  const parsedValue = parseNumericValue(value);
  const shouldShowChange = typeof change === "number" && Number.isFinite(change);
  const derivedTrend = trend ?? getTrend(change);

  return (
    <span className={cn("inline-flex items-center gap-2", className)}>
      <span className={cn("font-mono tabular-nums", priceClassName)}>
        {parsedValue === null
          ? fallback
          : formatPrice({
              compact,
              currency,
              locale,
              maximumFractionDigits,
              minimumFractionDigits,
              value: parsedValue,
            })}
      </span>

      {shouldShowChange && (
        <span
          className={cn(
            "inline-flex items-center gap-0.5 font-mono text-xs tabular-nums",
            trendClasses[derivedTrend],
            changeClassName
          )}
        >
          {showChangeIcon && <TrendIcon trend={derivedTrend} />}
          {formatChange(change, changeFractionDigits, hidePositiveSign)}
        </span>
      )}
    </span>
  );
}
lib/onchain/format.ts
export function truncateAddress(address: string, chars = 4): string {
  if (!address) return "";

  return `${address.slice(0, chars + 2)}...${address.slice(-chars)}`;
}

export type NumericValue = number | string | null | undefined;

/** Parses numbers and numeric strings; anything else becomes null. */
export function parseNumericValue(value: NumericValue): number | null {
  if (typeof value === "number") return Number.isFinite(value) ? value : null;
  if (typeof value === "string" && value.trim() !== "") {
    const parsed = Number(value);
    return Number.isFinite(parsed) ? parsed : null;
  }
  return null;
}

lib/onchain/crypto-icons.tsx
import { getAddress } from "viem";
import type { ReactNode } from "react";

/**
 * Crypto icon adapter.
 *
 * This file is the single seam onchain-ui components use to resolve token and
 * network icons — the crypto equivalent of shadcn's icon library setting.
 * Swap the implementations to point at your icon package, token list, CDN, or
 * indexer; the component contract stays the same.
 *
 * Both functions may return:
 * - a string, treated as an image URL
 * - a ReactNode, rendered directly in place of the image
 * - null, meaning "unknown" — components fall back to readable initials
 *
 * Explicit props (`src`, `fallback`) always win over this adapter.
 */

export type CryptoIcon = string | ReactNode | null;

export interface TokenIconQuery {
  /** EVM chain id the token lives on */
  chainId?: number | null;
  /** Token contract address */
  address?: string | null;
  /** Token symbol. Unused by the default adapter: symbols are not canonical */
  symbol?: string | null;
  /** Whether this is the chain's native currency rather than an ERC-20 */
  isNative?: boolean;
}

// Chain folder names in the Trust Wallet assets repo.
const trustWalletChainFolders: Record<number, string> = {
  1: "ethereum",
  10: "optimism",
  137: "polygon",
  8453: "base",
  42161: "arbitrum",
};

/**
 * Resolves a token icon from the community-maintained Trust Wallet assets
 * repo by chain id and contract address. Coverage is good for established
 * tokens; misses degrade to the component's initials fallback.
 *
 * This default is fine for demos and prototypes. In production, prefer your
 * own token metadata (`src`) or point this function at your token list or
 * CDN.
 */
export function getTokenIcon(query: TokenIconQuery): CryptoIcon {
  const { chainId, address, isNative, symbol } = query;
  if (isNative && symbol?.toUpperCase() === "ETH") {
    return <EthereumMark className="size-full" />;
  }
  if (chainId == null || !address) return null;

  const chainFolder = trustWalletChainFolders[chainId];
  if (!chainFolder) return null;

  try {
    // The repo stores assets under EIP-55 checksummed addresses.
    const checksummed = getAddress(address);
    return `https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/${chainFolder}/assets/${checksummed}/logo.png`;
  } catch {
    return null;
  }
}

function EthereumMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 32 32" aria-hidden="true" className={className}>
      <circle cx="16" cy="16" r="16" fill="#627EEA" />
      <g fill="#FFF">
        <path fillOpacity=".602" d="M16.498 4v8.87l7.497 3.35z" />
        <path d="M16.498 4L9 16.22l7.498-3.35z" />
        <path fillOpacity=".602" d="M16.498 21.968v6.027L24 17.616z" />
        <path d="M16.498 27.995v-6.028L9 17.616z" />
        <path fillOpacity=".2" d="M16.498 20.573l7.497-4.353-7.497-3.348z" />
        <path fillOpacity=".602" d="M9 16.22l7.498 4.353v-7.701z" />
      </g>
    </svg>
  );
}

function BaseMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 146 146" aria-hidden="true" className={className}>
      <circle cx="73" cy="73" r="73" fill="#0052FF" />
      <path
        d="M73.323 123.729C101.617 123.729 124.553 100.832 124.553 72.5875C124.553 44.343 101.617 21.4463 73.323 21.4463C46.4795 21.4463 24.4581 42.0558 22.271 68.2887H89.9859V76.8864H22.271C24.4581 103.119 46.4795 123.729 73.323 123.729Z"
        fill="white"
      />
    </svg>
  );
}

function OptimismMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className={className}>
      <circle cx="12" cy="12" r="12" fill="#FF0420" />
      <path
        fillRule="evenodd"
        clipRule="evenodd"
        d="M4.85865 15.3781C5.43829 15.7927 6.18265 16 7.09173 16C8.19009 16 9.06774 15.7517 9.72466 15.2553C10.3809 14.7516 10.8427 13.9926 11.1099 12.978C11.2703 12.3558 11.4079 11.7149 11.5226 11.0554C11.5598 10.8196 11.5788 10.6231 11.5795 10.4659C11.5795 9.94787 11.4459 9.50377 11.1786 9.13373C10.9039 8.74954 10.5214 8.4555 10.0793 8.28884C9.61364 8.09628 9.08706 8 8.49956 8C6.34017 8 5.00078 9.01845 4.48139 11.0554C4.29802 11.804 4.15686 12.4448 4.05796 12.978C4.02335 13.176 4.00397 13.3764 4 13.5773C4 14.3554 4.28622 14.9557 4.85865 15.3781ZM8.99176 12.9112C8.77268 13.762 8.16848 14.3161 7.25186 14.3161C6.34508 14.3161 6.03561 13.703 6.19869 12.9112C6.33623 12.1927 6.47377 11.5852 6.61132 11.0888C6.8471 10.1702 7.40316 9.68389 8.35023 9.68389C9.25407 9.68389 9.5488 10.2891 9.3926 11.0888C9.3009 11.6075 9.16729 12.215 8.99176 12.9112ZM12.4608 15.9204C12.5059 15.9728 12.5691 15.999 12.6504 15.999H14.1653C14.2427 15.9981 14.3174 15.9703 14.3765 15.9204C14.4423 15.8699 14.4862 15.796 14.4993 15.7141L14.9778 13.4742H16.5182C17.4909 13.4742 18.2513 13.0026 18.8083 12.583C19.3729 12.1638 19.7477 11.5162 19.9332 10.6398C19.9784 10.4342 20.0006 10.236 20 10.0454C20 9.38327 19.7477 8.87666 19.2435 8.5256C18.7457 8.1752 18.0849 8 17.2609 8H14.2989C14.2209 8.00126 14.1458 8.02976 14.0867 8.08056C14.0222 8.13166 13.9788 8.20477 13.9649 8.28589L12.4274 15.7141C12.411 15.7846 12.423 15.8587 12.4608 15.9204ZM17.874 10.6172C17.7345 11.2293 17.201 11.7893 16.5742 11.7893H15.2941L15.7352 9.68389H17.0713C17.5262 9.68389 17.9064 9.77428 17.9064 10.2743C17.9064 10.3726 17.8963 10.4868 17.874 10.6172Z"
        fill="white"
      />
    </svg>
  );
}

function ArbitrumMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className={className}>
      <circle cx="12" cy="12" r="12" fill="#213147" />
      <path
        d="M13.203 13.2158L12.4164 15.3402C12.3951 15.3993 12.3951 15.4636 12.4164 15.5227L13.7695 19.178L15.3346 18.2879L13.4562 13.2158C13.4136 13.099 13.2456 13.099 13.203 13.2158Z"
        fill="white"
      />
      <path
        d="M14.78 9.64306C14.7373 9.52621 14.5693 9.52621 14.5267 9.64306L13.7402 11.7674C13.7188 11.8265 13.7188 11.8909 13.7402 11.9499L15.9571 17.9345L17.5221 17.0443L14.78 9.64306Z"
        fill="white"
      />
      <path
        d="M11.9985 4.9913C12.0371 4.9913 12.0758 5.0018 12.1104 5.02019L18.0787 8.41424C18.148 8.45363 18.1907 8.52716 18.1907 8.60462V15.3914C18.1907 15.4702 18.148 15.5424 18.0787 15.5818L12.1104 18.9759C12.0772 18.9955 12.0371 19.0047 11.9985 19.0047C11.9598 19.0047 11.9211 18.9943 11.8865 18.9759L5.91826 15.5844C5.84894 15.545 5.80628 15.4715 5.80628 15.3941V8.60593C5.80628 8.52716 5.84894 8.45494 5.91826 8.41555L11.8865 5.0215C11.9211 5.0018 11.9598 4.9913 11.9985 4.9913ZM11.9985 4C11.7865 4 11.5732 4.05383 11.3826 4.16281L5.41568 7.55555C5.03442 7.77219 4.7998 8.17265 4.7998 8.60593V15.3927C4.7998 15.826 5.03442 16.2265 5.41568 16.4431L11.3839 19.8371C11.5745 19.9448 11.7865 20 11.9998 20C12.2118 20 12.425 19.9462 12.6157 19.8371L18.5839 16.4431C18.9652 16.2265 19.1998 15.826 19.1998 15.3927V8.60593C19.1998 8.17265 18.9652 7.77219 18.5839 7.55555L12.6143 4.16281C12.4237 4.05383 12.2104 4 11.9985 4Z"
        fill="white"
      />
      <path d="M8.05225 17.9426L8.60149 16.4615L9.70662 17.3662L8.67348 18.2958L8.05225 17.9426Z" fill="white" />
      <path
        d="M11.4965 8.12012H9.98351C9.87019 8.12012 9.76888 8.1897 9.73022 8.29474L6.48682 17.0523L8.05186 17.9425L11.6232 8.29868C11.6565 8.21203 11.5912 8.12012 11.4965 8.12012Z"
        fill="white"
      />
      <path
        d="M14.144 8.12012H12.6309C12.5176 8.12012 12.4163 8.1897 12.3776 8.29474L8.67432 18.2944L10.2393 19.1846L14.2706 8.29868C14.3026 8.21203 14.2373 8.12012 14.144 8.12012Z"
        fill="white"
      />
    </svg>
  );
}

function PolygonMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className={className}>
      <defs>
        <linearGradient
          id="onchain-ui-polygon-bg"
          x1="3.948"
          y1="16.617"
          x2="19.217"
          y2="7.645"
          gradientUnits="userSpaceOnUse"
        >
          <stop stopColor="#A726C1" />
          <stop offset="0.88" stopColor="#803BDF" />
          <stop offset="1" stopColor="#7B3FE4" />
        </linearGradient>
      </defs>
      <circle cx="12" cy="12" r="12" fill="url(#onchain-ui-polygon-bg)" />
      <path
        d="M15.8794 14.8595L19.6745 12.6947C19.8757 12.5797 20 12.3664 20 12.1375V7.80782C20 7.57894 19.8757 7.36569 19.6745 7.25069L15.8794 5.08583C15.6782 4.97083 15.4284 4.97195 15.2284 5.08583L11.4332 7.25069C11.232 7.36569 11.1077 7.57894 11.1077 7.80782V15.545L8.44614 17.0623L5.78456 15.545V12.5093L8.44614 10.992L10.2013 11.9935V9.95705L8.77163 9.1409C8.67331 9.08508 8.56029 9.05493 8.44614 9.05493C8.33199 9.05493 8.21897 9.08508 8.12065 9.1409L4.32549 11.3058C4.12432 11.4208 4 11.634 4 11.8629V16.1926C4 16.4215 4.12432 16.6347 4.32549 16.7497L8.12065 18.9146C8.32182 19.0285 8.57046 19.0285 8.77163 18.9146L12.5668 16.7497C12.768 16.6347 12.8923 16.4215 12.8923 16.1926V8.45538L12.9397 8.42859L15.5527 6.93808L18.2143 8.45538V11.4911L15.5527 13.0084L13.7998 12.0092V14.0456L15.2272 14.8595C15.4284 14.9734 15.6782 14.9734 15.8782 14.8595H15.8794Z"
        fill="white"
      />
    </svg>
  );
}

/**
 * Resolves a network icon by chain id. The default adapter ships crisp inline
 * marks for common chains so `<NetworkLogo chainId={8453} />` works offline
 * with zero configuration.
 */
export function getNetworkIcon(chainId?: number | null): CryptoIcon {
  if (chainId === 1) return <EthereumMark className="size-full" />;
  if (chainId === 10) return <OptimismMark className="size-full" />;
  if (chainId === 137) return <PolygonMark className="size-full" />;
  if (chainId === 8453) return <BaseMark className="size-full" />;
  if (chainId === 42161) return <ArbitrumMark className="size-full" />;
  return null;
}
lib/onchain/config.ts
import {
  arbitrum,
  base,
  mainnet,
  optimism,
  polygon,
  robinhood,
} from "viem/chains";
import type { AssetGatewayUrls, Chain, PublicClient } from "viem";

/** The small client surface identity resolution actually consumes. */
export type OnchainReadClient = Pick<
  PublicClient,
  "getEnsAddress" | "getEnsAvatar" | "getEnsName" | "readContract"
>;

/**
 * The single place onchain-ui components look for app-wide settings.
 *
 * This file is yours: edit it once and every component follows. There is no
 * provider and no context — components import these functions directly, so
 * they work the same in a server component, a test, or a non-React consumer.
 *
 * Per-instance overrides always win. `<NetworkLogo name="…" />` and
 * `<AddressDisplay explorerUrl="…" />` beat anything configured here.
 */

export interface OnchainUIConfig {
  /**
   * Chains your app supports. Supplies block explorer URLs and display names
   * for networks that have no built-in entry below.
   *
   * Using wagmi? Point this at the config you already wrote:
   *
   * ```ts
   * import { wagmiConfig } from "@/lib/onchain/wagmi";
   * chains: wagmiConfig.chains,
   * ```
   */
  chains: readonly Chain[];

  /**
   * Returns a viem client for reads that are not tied to a rendered component
   * — currently ENS and Basename resolution. Leave undefined to use the public
   * endpoints below.
   *
   * Using wagmi? Share its transports instead of configuring RPC twice:
   *
   * ```ts
   * import { getPublicClient } from "@wagmi/core";
   * import { wagmiConfig } from "@/lib/onchain/wagmi";
   * getClient: (chainId) => getPublicClient(wagmiConfig, { chainId }),
   * ```
   */
  getClient?: (chainId: number) => OnchainReadClient | undefined;

  /**
   * Public gateways used to turn IPFS and Arweave avatar records into URLs.
   * Replace these with your own gateway in production when reliability or
   * rate limits matter.
   */
  avatarGatewayUrls?: AssetGatewayUrls;
}

export const onchainConfig: OnchainUIConfig = {
  chains: [mainnet, base, arbitrum, optimism, polygon, robinhood],
  avatarGatewayUrls: {
    ipfs: "https://gateway.pinata.cloud",
    arweave: "https://arweave.net",
  },
};

/**
 * Curated network names and symbols.
 *
 * These are deliberately not derived from the viem chain: `chain.name` gives
 * "OP Mainnet" where a badge wants "Optimism", and `nativeCurrency.symbol`
 * gives "ETH" for Base, Optimism, and Arbitrum alike, which is useless as a
 * network mark. Chains absent here fall back to the configured chain's name.
 */
const knownNetworks: Partial<
  Record<number, { name: string; symbol: string }>
> = {
  1: { name: "Ethereum", symbol: "ETH" },
  10: { name: "Optimism", symbol: "OP" },
  137: { name: "Polygon", symbol: "POL" },
  4663: { name: "Robinhood Chain", symbol: "RH" },
  8453: { name: "Base", symbol: "BASE" },
  42161: { name: "Arbitrum", symbol: "ARB" },
};

function findChain(chainId?: number | null) {
  if (chainId == null) return null;
  return onchainConfig.chains.find((chain) => chain.id === chainId) ?? null;
}

export interface NetworkMeta {
  name: string | null;
  symbol: string | null;
}

/** Display name and symbol for a chain, or nulls when it is unrecognised. */
export function getNetworkMeta(chainId?: number | null): NetworkMeta {
  if (chainId == null) return { name: null, symbol: null };

  const known = knownNetworks[chainId];
  if (known) return known;

  return { name: findChain(chainId)?.name ?? null, symbol: null };
}

/**
 * Block explorer base URL for a chain, or null when we have none.
 *
 * Derived from the configured chains rather than a second hardcoded map —
 * viem's chain objects already carry `blockExplorers`. Null is a real answer:
 * an address on an unrecognised chain does not exist in mainnet's address
 * space, so callers render no link rather than a misleading one.
 */
export function getExplorerUrl(chainId?: number | null): string | null {
  const chain = findChain(chainId);
  return chain?.blockExplorers?.default.url ?? null;
}

/** A read client for `chainId`, if the app configured one. */
export function getConfiguredClient(
  chainId: number
): OnchainReadClient | undefined {
  return onchainConfig.getClient?.(chainId);
}

/** App-wide gateways for ENS and Basename avatar assets, if configured. */
export function getConfiguredAvatarGatewayUrls(): AssetGatewayUrls | undefined {
  return onchainConfig.avatarGatewayUrls;
}

More from onchain-ui

Browse all components in this library →