Skip to content

Connect Wallet Button

Opens a wallet list, spins while connecting, then morphs into an address pill.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/connect-wallet-button.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion radix-ui 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/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
    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. Add the theme tokens

    This component uses --success, which shadcn does not define by default. Add it to your global CSS:

    app/globals.css
    :root {
      --success: #15803d;
    }
    
    .dark {
      --success: #4ade80;
    }
    
    @theme inline {
      --color-success: var(--success);
    }
  4. Copy the source

    components/ui/connect-wallet-button/connect-wallet-button.tsx
    "use client";
    
    import { useEffect, useState, type ComponentProps } from "react";
    import { DropdownMenu } from "radix-ui";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { AlertCircle, ArrowUpRight, Check, ChevronDown, Copy, LogOut, Wallet } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { duration, ease, overlayVariants, spring } from "@/lib/motion";
    import { checksumAddress, truncateAddress } from "@/lib/web3/format";
    import { AddressAvatar, WalletIcon } from "@/lib/web3/icons";
    import { explorerLink, type WalletOption } from "@/lib/web3/types";
    
    export type ConnectStatus = "disconnected" | "connecting" | "connected" | "error";
    
    export interface ConnectWalletButtonProps
      extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Connection state, owned by your wallet layer. */
      status: ConnectStatus;
      /** Wallets offered in the list. */
      wallets: WalletOption[];
      /** Called with a wallet id when the user picks one. */
      onConnect: (walletId: string) => void;
      /** Connected account address. */
      address?: string;
      /** Display name for the connected account (e.g. an ENS name). */
      name?: string;
      /** Wallet being connected, named in the connecting state. */
      connectingWalletId?: string;
      /** Block explorer base URL for the "View on explorer" action. */
      explorerUrl?: string;
      /** Called from the account menu's "Disconnect" action. */
      onDisconnect?: () => void;
      /** Label of the idle button. @default "Connect wallet" */
      label?: string;
      /** Label shown after a failed or rejected connection. @default "Connection failed" */
      errorLabel?: string;
    }
    
    const swap = (reduceMotion: boolean) =>
      reduceMotion
        ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
        : {
            initial: { opacity: 0, y: 6, filter: "blur(4px)" },
            animate: { opacity: 1, y: 0, filter: "blur(0px)" },
            exit: { opacity: 0, y: -6, filter: "blur(4px)" },
          };
    
    const itemClass =
      "flex h-10 cursor-pointer items-center gap-3 rounded-lg px-2.5 text-sm text-foreground outline-none transition-colors data-[highlighted]:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50";
    
    export function ConnectWalletButton({
      status,
      wallets,
      onConnect,
      address,
      name,
      connectingWalletId,
      explorerUrl,
      onDisconnect,
      label = "Connect wallet",
      errorLabel = "Connection failed",
      className,
      ...props
    }: ConnectWalletButtonProps) {
      const reduceMotion = useReducedMotion() ?? false;
      const [open, setOpen] = useState(false);
      const [copied, setCopied] = useState(false);
      const connected = status === "connected" && address !== undefined;
      const checksummed = address ? checksumAddress(address) : "";
      const connectingWallet = wallets.find((w) => w.id === connectingWalletId);
      const href = connected ? explorerLink(explorerUrl, "address", checksummed) : undefined;
      const variants = swap(reduceMotion);
      const contentKey = connected ? "connected" : status;
    
      useEffect(() => {
        if (!copied) return;
        const timer = window.setTimeout(() => setCopied(false), 1600);
        return () => window.clearTimeout(timer);
      }, [copied]);
    
      async function copyAddress() {
        try {
          await navigator.clipboard.writeText(checksummed);
          setCopied(true);
        } catch {
          // Clipboard can be blocked (insecure context, permissions); the menu simply stays as is.
        }
      }
    
      const announcement = {
        disconnected: "",
        connecting: `Connecting${connectingWallet ? ` to ${connectingWallet.name}` : ""}`,
        connected: connected ? `Connected as ${name ?? truncateAddress(checksummed)}` : "",
        error: errorLabel,
      }[status];
    
      return (
        <div className={cn("inline-flex", className)} {...props}>
          <DropdownMenu.Root open={open} onOpenChange={(next) => setOpen(status === "connecting" ? false : next)}>
            <DropdownMenu.Trigger asChild>
              <motion.button
                type="button"
                layout
                aria-busy={status === "connecting" || undefined}
                aria-label={connected ? `Account ${name ?? truncateAddress(checksummed)}` : undefined}
                transition={reduceMotion ? { duration: 0 } : { layout: spring.smooth, x: { duration: 0.42, ease: "easeInOut" } }}
                animate={status === "error" && !reduceMotion ? { x: [0, -6, 6, -4, 4, 0] } : { x: 0 }}
                style={{ borderRadius: 999 }}
                className={cn(
                  "relative inline-flex h-10 shrink-0 cursor-pointer items-center overflow-hidden text-sm font-medium whitespace-nowrap outline-none select-none",
                  "transition-[background-color,border-color,color,box-shadow] duration-200 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                  connected
                    ? "border border-border bg-background pr-3 pl-1 text-foreground shadow-xs hover:bg-surface"
                    : status === "error"
                      ? "border border-destructive/30 bg-destructive/8 px-4 text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))] hover:bg-destructive/12"
                      : "bg-foreground px-4 text-background hover:bg-foreground/90",
                  status === "connecting" && "cursor-progress",
                )}
              >
                <AnimatePresence mode="popLayout" initial={false}>
                  <motion.span
                    key={contentKey}
                    className="flex items-center gap-2"
                    variants={variants}
                    initial="initial"
                    animate="animate"
                    exit="exit"
                    transition={{ duration: duration.quick, ease: ease.out }}
                  >
                    {connected ? (
                      <>
                        <AddressAvatar address={checksummed} size={30} />
                        <span className={cn("pl-0.5", name ? "" : "font-mono tabular-nums")}>{name ?? truncateAddress(checksummed)}</span>
                        <ChevronDown
                          className={cn("size-4 text-muted-foreground transition-transform duration-200", open && "rotate-180")}
                          aria-hidden="true"
                        />
                      </>
                    ) : status === "connecting" ? (
                      <>
                        <svg className="size-4 animate-spin motion-reduce:animate-pulse" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                          <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2.5" />
                          <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
                        </svg>
                        {connectingWallet ? `Opening ${connectingWallet.name}…` : "Connecting…"}
                      </>
                    ) : status === "error" ? (
                      <>
                        <AlertCircle className="size-4" aria-hidden="true" />
                        {errorLabel}
                        <span className="opacity-60">· Retry</span>
                      </>
                    ) : (
                      <>
                        <Wallet className="size-4" aria-hidden="true" />
                        {label}
                      </>
                    )}
                  </motion.span>
                </AnimatePresence>
              </motion.button>
            </DropdownMenu.Trigger>
    
            <AnimatePresence>
              {open ? (
                <DropdownMenu.Portal forceMount>
                  <DropdownMenu.Content asChild forceMount align="end" sideOffset={8} collisionPadding={12}>
                    <motion.div
                      variants={overlayVariants(reduceMotion)}
                      initial="hidden"
                      animate="visible"
                      exit="exit"
                      className="z-50 w-64 origin-[var(--radix-dropdown-menu-content-transform-origin)] rounded-xl border border-border bg-popover p-1.5 text-popover-foreground shadow-lg outline-none"
                    >
                      {connected ? (
                        <>
                          <div className="flex items-center gap-3 px-2.5 pt-2 pb-3">
                            <AddressAvatar address={checksummed} size={36} />
                            <div className="min-w-0">
                              {name ? <p className="truncate text-sm font-medium text-foreground">{name}</p> : null}
                              <p className="font-mono text-xs text-muted-foreground tabular-nums">{truncateAddress(checksummed)}</p>
                            </div>
                          </div>
                          <DropdownMenu.Separator className="mx-1 mb-1 h-px bg-border" />
                          <DropdownMenu.Item
                            className={itemClass}
                            onSelect={(event) => {
                              event.preventDefault();
                              void copyAddress();
                            }}
                          >
                            <span className="grid size-4 place-items-center text-muted-foreground *:[grid-area:1/1]" aria-hidden="true">
                              <AnimatePresence initial={false}>
                                <motion.span
                                  key={copied ? "check" : "copy"}
                                  initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: "blur(3px)" }}
                                  animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                                  exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: "blur(3px)" }}
                                  transition={{ duration: duration.quick }}
                                  className={cn(copied && "text-success")}
                                >
                                  {copied ? <Check className="size-4" /> : <Copy className="size-4" />}
                                </motion.span>
                              </AnimatePresence>
                            </span>
                            {copied ? "Copied" : "Copy address"}
                          </DropdownMenu.Item>
                          {href ? (
                            <DropdownMenu.Item asChild className={itemClass}>
                              <a href={href} target="_blank" rel="noreferrer">
                                <ArrowUpRight className="size-4 text-muted-foreground" aria-hidden="true" />
                                View on explorer
                              </a>
                            </DropdownMenu.Item>
                          ) : null}
                          <DropdownMenu.Item
                            className={cn(itemClass, "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))] data-[highlighted]:bg-destructive/8")}
                            onSelect={() => onDisconnect?.()}
                          >
                            <LogOut className="size-4" aria-hidden="true" />
                            Disconnect
                          </DropdownMenu.Item>
                        </>
                      ) : (
                        <>
                          <DropdownMenu.Label className="px-2.5 pt-1.5 pb-2 text-xs font-medium text-muted-foreground">
                            Choose a wallet
                          </DropdownMenu.Label>
                          {wallets.map((wallet) => (
                            <DropdownMenu.Item
                              key={wallet.id}
                              className={cn(itemClass, "h-12")}
                              onSelect={() => onConnect(wallet.id)}
                            >
                              <WalletIcon wallet={wallet} size={30} />
                              <span className="min-w-0 flex-1">
                                <span className="block truncate font-medium">{wallet.name}</span>
                                {wallet.description || wallet.installed ? (
                                  <span className="block truncate text-xs text-muted-foreground">
                                    {wallet.description ?? "Detected"}
                                  </span>
                                ) : null}
                              </span>
                              {wallet.recent ? (
                                <span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
                                  Recent
                                </span>
                              ) : null}
                            </DropdownMenu.Item>
                          ))}
                        </>
                      )}
                    </motion.div>
                  </DropdownMenu.Content>
                </DropdownMenu.Portal>
              ) : null}
            </AnimatePresence>
          </DropdownMenu.Root>
          <span className="sr-only" role="status" aria-live="polite">
            {announcement}
          </span>
        </div>
      );
    }

Usage

import { ConnectWalletButton } from "@/components/ui/connect-wallet-button/connect-wallet-button";

// Presentational: wire it to wagmi, viem or your own wallet layer.
<ConnectWalletButton
  status={status} // "disconnected" | "connecting" | "connected" | "error"
  wallets={[{ id: "harbor", name: "Harbor Wallet", recent: true }]}
  onConnect={(walletId) => connect(walletId)}
  address={account?.address}
  explorerUrl="https://etherscan.io"
  onDisconnect={disconnect}
/>

API reference

PropTypeDefaultDescription
status*ConnectStatus–Connection state, owned by your wallet layer.
wallets*WalletOption[]–Wallets offered in the list.
onConnect*(walletId: string) => void–Called with a wallet id when the user picks one.
addressstring–Connected account address.
namestring–Display name for the connected account (e.g. an ENS name).
connectingWalletIdstring–Wallet being connected, named in the connecting state.
explorerUrlstring–Block explorer base URL for the "View on explorer" action.
onDisconnect() => void–Called from the account menu's "Disconnect" action.
labelstring"Connect wallet"Label of the idle button.
errorLabelstring"Connection failed"Label shown after a failed or rejected connection.

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

  • The component never talks to a wallet. Your wallet layer owns status and address; the component calls onConnect and onDisconnect.
  • Wallet icons are generated from the wallet name and color. Pass iconUrl on a wallet to show its real logo.

Accessibility

  • The button opens a menu of wallets with full arrow-key navigation; Enter or Space picks one.
  • Connecting sets aria-busy and blocks the menu, and every state change is announced through a polite live region.
  • Once connected, the button is named after the account and opens a menu with Copy address, View on explorer and Disconnect.
  • Reduced motion swaps the content with a plain fade, skips the width morph and drops the error shake.