shadimage
onchain-ui
uiaddress-displaysource: ok · hit

Address Display

Displays an EVM wallet address with copy-to-clipboard and an optional block explorer link.

npx shadcn add "https://onchain-ui.dev/r/address-display.json"
free

Where it goes

kanban

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

Dependencies

Bundle weight · light
toastssonner
iconslucide-react

Registry dependencies

@onchain-ui/sonner

Files3 · source cached 10/13/2026, 9:21:10 AM

registry/onchain-ui/address-display.tsx
"use client";

import { toast } from "sonner";
import { CopyIcon, ExternalLinkIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { truncateAddress } from "@/lib/onchain/format";
import { getExplorerUrl } from "@/lib/onchain/config";
import type { Address } from "viem";
import type { ReactNode } from "react";

export interface AddressDisplayProps {
  /** EVM wallet address */
  address: Address;
  /** Display label. The copied value is always the underlying address. */
  label?: ReactNode;
  /** Applied to the root wrapper */
  className?: string;
  /** Applied to the address text or copy trigger */
  addressClassName?: string;
  /** Truncate the address. Default: true */
  truncate?: boolean;
  /** Number of characters to show at each end when truncated. Default: 4 */
  truncateChars?: number;
  /** Show copy-to-clipboard button. Default: true */
  showCopy?: boolean;
  /** Show block explorer link. Default: true */
  showExplorer?: boolean;
  /** EVM chain id used to pick a known block explorer. Default: 1 (Etherscan) */
  chainId?: number | null;
  /** Full explorer URL override e.g. "https://basescan.org/address/0x..." */
  explorerUrl?: string;
  /** Icon rendered before the address when copy is enabled */
  copyIcon?: ReactNode;
  /** Icon rendered for the explorer link */
  explorerIcon?: ReactNode;
}

export function AddressDisplay({
  address,
  label,
  className,
  addressClassName,
  truncate = true,
  truncateChars = 4,
  showCopy = true,
  showExplorer = true,
  chainId,
  explorerUrl,
  copyIcon = <CopyIcon className="size-3 shrink-0" />,
  explorerIcon = <ExternalLinkIcon className="size-3 shrink-0" />,
}: AddressDisplayProps) {
  const handleCopy = async () => {
    try {
      await navigator.clipboard.writeText(address);
      toast.success("Address copied");
    } catch {
      toast.error("Could not copy address");
    }
  };

  // No chain id means "assume mainnet", the documented default. A chain id we
  // do not recognise is different: linking it to Etherscan would point at a
  // page about a different chain's address space. Drop the link instead and
  // let `explorerUrl` cover the long tail.
  const explorerBase = getExplorerUrl(chainId ?? 1);
  const href =
    explorerUrl ??
    (explorerBase ? `${explorerBase}/address/${address}` : null);
  const displayLabel =
    label ?? (truncate ? truncateAddress(address, truncateChars) : address);
  const labelClassName = truncate ? "whitespace-nowrap" : "break-all";

  return (
    <div className={cn("flex min-w-0 items-center gap-1.5", className)}>
      {showCopy ? (
        <button
          type="button"
          onClick={handleCopy}
          title="Copy address"
          className={cn(
            "flex min-w-0 items-center gap-1.5 font-mono text-xs text-muted-foreground",
            "hover:text-foreground cursor-copy transition-colors duration-150",
            addressClassName
          )}
        >
          {copyIcon}
          <span className={labelClassName}>{displayLabel}</span>
        </button>
      ) : (
        <span
          className={cn(
            "min-w-0 font-mono text-xs text-muted-foreground",
            labelClassName,
            addressClassName
          )}
        >
          {displayLabel}
        </span>
      )}

      {showExplorer && href && (
        <a
          href={href}
          target="_blank"
          rel="noopener noreferrer"
          title="View on explorer"
          className="text-muted-foreground hover:text-foreground transition-colors duration-150"
        >
          {explorerIcon}
        </a>
      )}
    </div>
  );
}
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/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 →