Skip to content

Wallet Selector Modal

Wallet list with a Recent badge that morphs into a QR code view.

Installation

CLI

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

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion radix-ui tailwind-merge
  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/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/wallet-selector-modal/wallet-selector-modal.tsx
    "use client";
    
    import { useState, type ReactNode } from "react";
    import { Dialog } from "radix-ui";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { AlertCircle, ArrowLeft, Check, ChevronRight, Copy, X } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { duration, ease, overlayVariants, spring } from "@/lib/motion";
    import { WalletIcon } from "@/lib/web3/icons";
    import type { WalletOption } from "@/lib/web3/types";
    
    export type WalletSelectorView = "list" | "qr";
    
    export interface WalletSelectorModalProps {
      /** Controlled open state. */
      open?: boolean;
      /** Initial open state when uncontrolled. @default false */
      defaultOpen?: boolean;
      /** Called when the dialog opens or closes. */
      onOpenChange?: (open: boolean) => void;
      /** Element that opens the dialog. */
      trigger?: ReactNode;
      /** Wallets to offer. Wallets with `qr` open the QR view. */
      wallets: WalletOption[];
      /** Called with the wallet id the user picked. */
      onSelect: (walletId: string) => void;
      /** State of the picked wallet's connection. @default "idle" */
      status?: "idle" | "connecting" | "error";
      /** The wallet the user picked, shown with a spinner or error. */
      selectedWalletId?: string;
      /** Controlled view. */
      view?: WalletSelectorView;
      /** Called when the view changes (picking a QR wallet, or going back). */
      onViewChange?: (view: WalletSelectorView) => void;
      /** Connection URI encoded in the QR view, also offered by "Copy link". */
      qrValue?: string;
      /** Renders a scannable QR code for qrValue. Without it, a decorative placeholder pattern is drawn. */
      renderQrCode?: (value: string) => ReactNode;
      /** Dialog title. @default "Connect a wallet" */
      title?: string;
      /** Message shown when status is "error". @default "Request rejected in your wallet." */
      errorMessage?: string;
    }
    
    const QR_SIZE = 25;
    
    /** Module path of a decorative, deterministic QR-like pattern (not scannable). */
    function qrModules(value: string) {
      let seed = 0x811c9dc5;
      for (const char of value) seed = Math.imul(seed ^ char.charCodeAt(0), 0x01000193);
      const random = () => {
        seed = Math.imul(seed ^ (seed >>> 15), 0x2c1b3c6d);
        seed = Math.imul(seed ^ (seed >>> 12), 0x297a2d39);
        seed ^= seed >>> 15;
        return (seed >>> 0) / 4294967296;
      };
      const inFinder = (x: number, y: number) => (x < 8 && y < 8) || (x > QR_SIZE - 9 && y < 8) || (x < 8 && y > QR_SIZE - 9);
      const inLogo = (x: number, y: number) => Math.abs(x - 12) <= 3 && Math.abs(y - 12) <= 3;
      let d = "";
      for (let y = 0; y < QR_SIZE; y++) {
        for (let x = 0; x < QR_SIZE; x++) {
          if (random() > 0.52 && !inFinder(x, y) && !inLogo(x, y)) d += `M${x} ${y}h1v1h-1z`;
        }
      }
      return d;
    }
    
    function QrPattern({ value }: { value: string }) {
      const size = QR_SIZE;
      const d = qrModules(value);
      const finder = (x: number, y: number) => (
        <g key={`${x}-${y}`}>
          <rect x={x + 0.5} y={y + 0.5} width="6" height="6" rx="1.6" fill="none" stroke="currentColor" />
          <rect x={x + 2} y={y + 2} width="3" height="3" rx="0.8" fill="currentColor" />
        </g>
      );
      return (
        <svg viewBox={`-1 -1 ${size + 2} ${size + 2}`} className="size-full" aria-hidden="true" shapeRendering="crispEdges">
          <path d={d} fill="currentColor" />
          <g shapeRendering="geometricPrecision">{[finder(0, 0), finder(size - 7, 0), finder(0, size - 7)]}</g>
        </svg>
      );
    }
    
    const rowClass =
      "group flex w-full cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-left outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-progress";
    
    export function WalletSelectorModal({
      open,
      defaultOpen = false,
      onOpenChange,
      trigger,
      wallets,
      onSelect,
      status = "idle",
      selectedWalletId,
      view,
      onViewChange,
      qrValue = "wc:hantui-demo@2?relay-protocol=irn",
      renderQrCode,
      title = "Connect a wallet",
      errorMessage = "Request rejected in your wallet.",
    }: WalletSelectorModalProps) {
      const reduceMotion = useReducedMotion() ?? false;
      const [internalOpen, setInternalOpen] = useState(defaultOpen);
      const [internalView, setInternalView] = useState<WalletSelectorView>("list");
      const [qrWalletId, setQrWalletId] = useState<string>();
      const [copied, setCopied] = useState(false);
      const isOpen = open ?? internalOpen;
      const currentView = view ?? internalView;
      const qrWallet = wallets.find((w) => w.id === (qrWalletId ?? selectedWalletId)) ?? wallets.find((w) => w.qr);
    
      const setOpen = (next: boolean) => {
        if (open === undefined) setInternalOpen(next);
        onOpenChange?.(next);
        if (!next) setView("list");
      };
      const setView = (next: WalletSelectorView) => {
        if (view === undefined) setInternalView(next);
        onViewChange?.(next);
      };
    
      function pick(wallet: WalletOption) {
        onSelect(wallet.id);
        if (wallet.qr) {
          setQrWalletId(wallet.id);
          setView("qr");
        }
      }
    
      async function copyLink() {
        try {
          await navigator.clipboard.writeText(qrValue);
          setCopied(true);
          window.setTimeout(() => setCopied(false), 1600);
        } catch {
          // Clipboard can be unavailable; nothing to undo.
        }
      }
    
      const direction = currentView === "qr" ? 1 : -1;
      const slide = reduceMotion
        ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
        : {
            initial: { opacity: 0, x: 24 * direction, filter: "blur(4px)" },
            animate: { opacity: 1, x: 0, filter: "blur(0px)" },
            exit: { opacity: 0, x: -24 * direction, filter: "blur(4px)" },
          };
    
      return (
        <Dialog.Root open={isOpen} onOpenChange={setOpen}>
          {trigger ? <Dialog.Trigger asChild>{trigger}</Dialog.Trigger> : null}
          <AnimatePresence>
            {isOpen ? (
              <Dialog.Portal forceMount>
                <Dialog.Overlay asChild forceMount>
                  <motion.div
                    className="fixed inset-0 z-50 bg-zinc-950/20 backdrop-blur-[3px]"
                    initial={{ opacity: 0 }}
                    animate={{ opacity: 1 }}
                    exit={{ opacity: 0 }}
                    transition={{ duration: duration.base }}
                  />
                </Dialog.Overlay>
                <div className="pointer-events-none fixed inset-0 z-50 grid place-items-center p-4">
                  <Dialog.Content asChild forceMount aria-describedby={undefined}>
                    <motion.div
                      layout
                      variants={overlayVariants(reduceMotion)}
                      initial="hidden"
                      animate="visible"
                      exit="exit"
                      transition={{ layout: reduceMotion ? { duration: 0 } : spring.smooth }}
                      style={{ borderRadius: 20 }}
                      className="pointer-events-auto w-full max-w-sm overflow-hidden border border-border bg-popover text-popover-foreground shadow-xl outline-none"
                    >
                      <AnimatePresence mode="popLayout" initial={false}>
                        <motion.div
                          key={currentView}
                          variants={slide}
                          initial="initial"
                          animate="animate"
                          exit="exit"
                          transition={{ duration: duration.base, ease: ease.out }}
                          className="p-2"
                        >
                          <div className="flex h-12 items-center gap-2 pr-1 pl-3">
                            {currentView === "qr" ? (
                              <button
                                type="button"
                                onClick={() => setView("list")}
                                aria-label="Back to wallets"
                                className="relative after:absolute after:-inset-0.5 -ml-2 inline-flex size-9 cursor-pointer items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                              >
                                <ArrowLeft className="size-4" aria-hidden="true" />
                              </button>
                            ) : null}
                            <Dialog.Title className="flex-1 text-base font-semibold tracking-tight text-foreground">
                              {currentView === "qr" ? `Scan with ${qrWallet?.name ?? "your phone"}` : title}
                            </Dialog.Title>
                            <Dialog.Close
                              aria-label="Close"
                              className="inline-flex size-9 cursor-pointer items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                            >
                              <X className="size-4" aria-hidden="true" />
                            </Dialog.Close>
                          </div>
    
                          {currentView === "list" ? (
                            <ul className="space-y-0.5 pb-1">
                              {wallets.map((wallet) => {
                                const active = wallet.id === selectedWalletId;
                                const connecting = active && status === "connecting";
                                const failed = active && status === "error";
                                return (
                                  <li key={wallet.id}>
                                    <button
                                      type="button"
                                      onClick={() => pick(wallet)}
                                      disabled={status === "connecting"}
                                      aria-busy={connecting || undefined}
                                      className={cn(rowClass, active && status !== "idle" && "bg-accent")}
                                    >
                                      <WalletIcon wallet={wallet} size={36} />
                                      <span className="min-w-0 flex-1">
                                        <span className="flex items-center gap-2 text-sm font-medium text-foreground">
                                          <span className="truncate">{wallet.name}</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}
                                        </span>
                                        <span
                                          className={cn(
                                            "block truncate text-xs",
                                            failed ? "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]" : "text-muted-foreground",
                                          )}
                                        >
                                          {connecting
                                            ? "Confirm in your wallet…"
                                            : failed
                                              ? errorMessage
                                              : (wallet.description ?? (wallet.installed ? "Detected in this browser" : "Not installed"))}
                                        </span>
                                      </span>
                                      {connecting ? (
                                        <svg className="size-4 animate-spin text-muted-foreground 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>
                                      ) : failed ? (
                                        <AlertCircle className="size-4 text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]" aria-hidden="true" />
                                      ) : (
                                        <ChevronRight
                                          className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
                                          aria-hidden="true"
                                        />
                                      )}
                                    </button>
                                  </li>
                                );
                              })}
                            </ul>
                          ) : (
                            <div className="flex flex-col items-center px-4 pt-2 pb-4">
                              <div className="relative aspect-square w-full max-w-60 rounded-2xl border border-border bg-background p-4 text-foreground">
                                {renderQrCode ? renderQrCode(qrValue) : <QrPattern value={qrValue} />}
                                {qrWallet ? (
                                  <span className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-xl bg-background p-1">
                                    <WalletIcon wallet={qrWallet} size={34} />
                                  </span>
                                ) : null}
                              </div>
                              <p className="mt-4 text-center text-[13px] leading-5 text-muted-foreground">
                                Open the wallet app on your phone and scan this code to connect.
                              </p>
                              <button
                                type="button"
                                onClick={copyLink}
                                className="relative after:absolute after:inset-x-0 after:-inset-y-0.5 mt-3 inline-flex h-9 cursor-pointer items-center gap-2 rounded-lg border border-border bg-background px-3 text-[13px] font-medium text-foreground shadow-xs outline-none transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-ring"
                              >
                                {copied ? <Check className="size-3.5 text-success" aria-hidden="true" /> : <Copy className="size-3.5" aria-hidden="true" />}
                                {copied ? "Link copied" : "Copy link"}
                              </button>
                            </div>
                          )}
                        </motion.div>
                      </AnimatePresence>
                      <span className="sr-only" role="status" aria-live="polite">
                        {status === "connecting" ? "Waiting for confirmation in your wallet" : status === "error" ? errorMessage : ""}
                      </span>
                    </motion.div>
                  </Dialog.Content>
                </div>
              </Dialog.Portal>
            ) : null}
          </AnimatePresence>
        </Dialog.Root>
      );
    }

Usage

import { WalletSelectorModal } from "@/components/ui/wallet-selector-modal/wallet-selector-modal";

<WalletSelectorModal
  trigger={<button>Connect wallet</button>}
  wallets={wallets} // { id, name, iconUrl?, recent?, installed?, qr? }[]
  selectedWalletId={pendingId}
  status={status} // "idle" | "connecting" | "error"
  onSelect={(id) => connect(id)}
  qrValue={walletConnectUri}
  renderQrCode={(value) => <YourQrCode value={value} />}
/>

API reference

PropTypeDefaultDescription
openboolean–Controlled open state.
defaultOpenbooleanfalseInitial open state when uncontrolled.
onOpenChange(open: boolean) => void–Called when the dialog opens or closes.
triggerReactNode–Element that opens the dialog.
wallets*WalletOption[]–Wallets to offer. Wallets with `qr` open the QR view.
onSelect*(walletId: string) => void–Called with the wallet id the user picked.
status"idle" | "connecting" | "error""idle"State of the picked wallet's connection.
selectedWalletIdstring–The wallet the user picked, shown with a spinner or error.
viewWalletSelectorView–Controlled view.
onViewChange(view: WalletSelectorView) => void–Called when the view changes (picking a QR wallet, or going back).
qrValuestring–Connection URI encoded in the QR view, also offered by "Copy link".
renderQrCode(value: string) => ReactNode–Renders a scannable QR code for qrValue. Without it, a decorative placeholder pattern is drawn.
titlestring"Connect a wallet"Dialog title.
errorMessagestring"Request rejected in your wallet."Message shown when status is "error".
  • The built-in QR pattern is decorative and can't be scanned. Pass renderQrCode to draw a real code with the QR library you already use.
  • Wallets with qr: true open the QR view when picked; other wallets only call onSelect, so your wallet layer decides what happens next.

Accessibility

  • Built on Radix Dialog: focus moves into the dialog, stays trapped while it's open and returns to the trigger on close. Escape closes it.
  • Each wallet is a button in a list; the one connecting sets aria-busy, and connecting or rejected states are announced through a live region.
  • The QR view has a labelled Back button and a Copy link fallback for people who can't scan.
  • Reduced motion replaces the slide and height morph with a cross-fade.