Skip to content

Account Dropdown

Rolling total balance, address actions and disconnect.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/account-dropdown.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/rolling-value.tsx
    "use client";
    
    import { useEffect, useState } from "react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    
    const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
    const REEL = { type: "spring", stiffness: 160, damping: 22, mass: 0.9 } as const;
    const MASK = "linear-gradient(to bottom, transparent, #000 22%, #000 78%, transparent)";
    
    function Reel({ digit, reduceMotion }: { digit: number; reduceMotion: boolean }) {
      return (
        <span className="relative inline-block [clip-path:inset(0)]" style={{ maskImage: MASK, WebkitMaskImage: MASK }}>
          <span className="invisible">0</span>
          <motion.span
            className="absolute inset-x-0 top-0 flex flex-col items-center"
            initial={false}
            animate={{ y: `${-digit * 10}%` }}
            transition={reduceMotion ? { duration: 0 } : REEL}
          >
            {DIGITS.map((n) => (
              <span key={n}>{n}</span>
            ))}
          </motion.span>
        </span>
      );
    }
    
    export interface RollingValueProps {
      /** Pre-formatted text, e.g. "$1,234.56" or "2.4815". Digits roll; everything else stays put. */
      value: string;
      /**
       * The raw value behind the text (a bigint amount or a number). When it changes, the text
       * flashes green for an increase and red for a decrease.
       */
      trend?: bigint | number;
      /** Flash on increases and decreases. @default true */
      flash?: boolean;
      className?: string;
    }
    
    type Flash = { direction: "up" | "down"; key: number };
    
    /**
     * Rolling digits for pre-formatted strings, so bigint amounts animate without ever becoming
     * floats. Uses tabular figures; screen readers get the plain text.
     */
    export function RollingValue({ value, trend, flash = true, className }: RollingValueProps) {
      const reduceMotion = useReducedMotion() ?? false;
      const [previous, setPrevious] = useState(trend);
      const [pulse, setPulse] = useState<Flash | null>(null);
    
      if (trend !== previous) {
        setPrevious(trend);
        if (flash && trend !== undefined && previous !== undefined) {
          setPulse({ direction: trend > previous ? "up" : "down", key: (pulse?.key ?? 0) + 1 });
        }
      }
    
      useEffect(() => {
        if (!pulse) return;
        const timer = window.setTimeout(() => setPulse(null), 900);
        return () => window.clearTimeout(timer);
      }, [pulse]);
    
      // Key characters by their distance from the end, so a given place value keeps its reel.
      const chars = [...value];
      return (
        <span
          className={cn(
            "relative inline-flex tabular-nums transition-colors duration-300",
            pulse?.direction === "up" && "text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))]",
            pulse?.direction === "down" && "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]",
            className,
          )}
        >
          <span className="sr-only">{value}</span>
          <AnimatePresence>
            {pulse && !reduceMotion ? (
              <motion.span
                key={pulse.key}
                aria-hidden="true"
                className={cn(
                  "pointer-events-none absolute -inset-x-1 inset-y-0 rounded-[4px]",
                  pulse.direction === "up" ? "bg-success/12" : "bg-destructive/12",
                )}
                initial={{ opacity: 1 }}
                animate={{ opacity: 0 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.9, ease: "easeOut" }}
              />
            ) : null}
          </AnimatePresence>
          <span aria-hidden="true" className="relative inline-flex select-none">
            {chars.map((char, index) => {
              const key = chars.length - index;
              return /\d/.test(char) ? <Reel key={key} digit={Number(char)} reduceMotion={reduceMotion} /> : <span key={`${key}${char}`}>{char}</span>;
            })}
          </span>
        </span>
      );
    }
    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/account-dropdown/account-dropdown.tsx
    "use client";
    
    import { useEffect, useState, type ComponentProps } from "react";
    import { DropdownMenu } from "radix-ui";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { ArrowUpRight, Check, ChevronDown, Copy, LogOut } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { duration, overlayVariants } from "@/lib/motion";
    import { checksumAddress, formatUsd, truncateAddress, USD_DECIMALS } from "@/lib/web3/format";
    import { AddressAvatar, ChainIcon } from "@/lib/web3/icons";
    import { RollingValue } from "@/lib/web3/rolling-value";
    import { explorerLink, type Chain } from "@/lib/web3/types";
    
    export interface AccountDropdownProps
      extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Connected account address. */
      address: string;
      /** Display name (e.g. an ENS name). */
      name?: string;
      /** Total balance in USD as a bigint with `balanceDecimals` decimals. */
      balance?: bigint;
      /** Decimals of `balance`. @default 8 */
      balanceDecimals?: number;
      /** Show skeletons while the balance loads. @default false */
      loading?: boolean;
      /** Current network, shown in the menu header. */
      chain?: Chain;
      /** Block explorer base URL. Falls back to the chain's explorer. */
      explorerUrl?: string;
      /** Show the balance in the trigger next to the address. @default true */
      showBalance?: boolean;
      /** Called from the "Disconnect" item. */
      onDisconnect?: () => void;
    }
    
    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";
    
    export function AccountDropdown({
      address,
      name,
      balance,
      balanceDecimals = USD_DECIMALS,
      loading = false,
      chain,
      explorerUrl,
      showBalance = true,
      onDisconnect,
      className,
      ...props
    }: AccountDropdownProps) {
      const reduceMotion = useReducedMotion() ?? false;
      const [copied, setCopied] = useState(false);
      const checksummed = checksumAddress(address);
      const short = truncateAddress(checksummed);
      const href = explorerLink(explorerUrl ?? chain?.explorerUrl, "address", checksummed);
      const total = balance === undefined ? undefined : formatUsd(balance, balanceDecimals);
    
      useEffect(() => {
        if (!copied) return;
        const timer = window.setTimeout(() => setCopied(false), 1600);
        return () => window.clearTimeout(timer);
      }, [copied]);
    
      const skeleton = (width: string) => (
        <span className={cn("inline-block h-[0.9em] animate-pulse rounded bg-muted align-middle motion-reduce:animate-none", width)} />
      );
    
      return (
        <div className={cn("inline-flex", className)} {...props}>
          <DropdownMenu.Root>
            <DropdownMenu.Trigger
              className="group inline-flex h-10 cursor-pointer items-center gap-2 rounded-full border border-border bg-background pr-3 pl-1 text-sm font-medium text-foreground shadow-xs outline-none transition-colors select-none hover:bg-surface focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
            >
              <AddressAvatar address={checksummed} size={30} />
              <span className="sr-only">Account </span>
              {showBalance ? (
                <span className="hidden border-r border-border pr-2 font-mono text-[13px] tabular-nums sm:inline-flex">
                  {loading || total === undefined ? skeleton("w-14") : <RollingValue value={total} trend={balance} />}
                </span>
              ) : null}
              <span className={cn(name ? "" : "font-mono tabular-nums")}>{name ?? short}</span>
              <ChevronDown className="size-4 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" aria-hidden="true" />
            </DropdownMenu.Trigger>
            <DropdownMenu.Portal>
              <DropdownMenu.Content align="end" sideOffset={8} collisionPadding={12} asChild>
                <motion.div
                  variants={overlayVariants(reduceMotion)}
                  initial="hidden"
                  animate="visible"
                  className="z-50 w-72 origin-[var(--radix-dropdown-menu-content-transform-origin)] rounded-2xl border border-border bg-popover p-1.5 text-popover-foreground shadow-lg outline-none"
                >
                  <div className="rounded-xl bg-surface p-3.5">
                    <div className="flex items-center gap-3">
                      <AddressAvatar address={checksummed} size={40} />
                      <div className="min-w-0 flex-1">
                        {name ? <p className="truncate text-sm font-semibold text-foreground">{name}</p> : null}
                        <p className="font-mono text-xs text-muted-foreground tabular-nums">{short}</p>
                      </div>
                      {chain ? (
                        <span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-background py-0.5 pr-2 pl-0.5 text-xs text-muted-foreground">
                          <ChainIcon chain={chain} size={16} />
                          {chain.shortName ?? chain.name}
                        </span>
                      ) : null}
                    </div>
                    <p className="mt-4 text-xs text-muted-foreground">Total balance</p>
                    <p className="mt-0.5 font-mono text-2xl font-semibold tracking-tight text-foreground tabular-nums">
                      {loading || total === undefined ? (
                        <>
                          {skeleton("w-32")}
                          <span className="sr-only">Loading balance</span>
                        </>
                      ) : (
                        <RollingValue value={total} trend={balance} />
                      )}
                    </p>
                  </div>
                  <div className="pt-1.5">
                    <DropdownMenu.Item
                      className={itemClass}
                      onSelect={(event) => {
                        event.preventDefault();
                        navigator.clipboard.writeText(checksummed).then(() => setCopied(true), () => {});
                      }}
                    >
                      <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>
                      <span aria-live="polite">{copied ? "Address copied" : "Copy address"}</span>
                    </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.Separator className="mx-1 my-1 h-px bg-border" />
                    <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>
                  </div>
                </motion.div>
              </DropdownMenu.Content>
            </DropdownMenu.Portal>
          </DropdownMenu.Root>
        </div>
      );
    }

Usage

import { AccountDropdown } from "@/components/ui/account-dropdown/account-dropdown";

<AccountDropdown
  address={account.address}
  name={ensName}
  balance={totalUsdValue} // bigint with 8 decimals
  chain={{ id: 1, name: "Ethereum", explorerUrl: "https://etherscan.io" }}
  onDisconnect={disconnect}
/>

API reference

PropTypeDefaultDescription
address*string–Connected account address.
namestring–Display name (e.g. an ENS name).
balancebigint–Total balance in USD as a bigint with `balanceDecimals` decimals.
balanceDecimalsnumber8Decimals of `balance`.
loadingbooleanfalseShow skeletons while the balance loads.
chainChain–Current network, shown in the menu header.
explorerUrlstring–Block explorer base URL. Falls back to the chain's explorer.
showBalancebooleantrueShow the balance in the trigger next to the address.
onDisconnect() => void–Called from the "Disconnect" item.

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

  • Balances are bigint values formatted as strings, so nothing is ever converted to a float. Increases flash green and decreases flash red.
  • The trigger hides the balance below the sm breakpoint to keep the address readable on phones.

Accessibility

  • The trigger is named after the account and opens a Radix dropdown menu with arrow-key navigation and typeahead.
  • Copy address keeps the menu open and confirms with a check and a polite "Address copied" message.
  • The rolling balance renders its full value as screen-reader text; the digit reels are aria-hidden.
  • Reduced motion keeps balance updates to a color change, with no rolling reels or highlight.