Skip to content

Cross-chain Bridge

An animated route between chains, fee breakdown and progress tracker.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/bridge-widget.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 --link, --success, and --success-foreground, which shadcn does not define by default. Add them to your global CSS (--link falls back to your --primary unless you set it):

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

    components/ui/bridge-widget/bridge-widget.tsx
    "use client";
    
    import { useState, type ComponentProps } from "react";
    import { DropdownMenu } from "radix-ui";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { ArrowLeftRight, ArrowUpRight, Check, ChevronDown, Clock3, X } from "lucide-react";
    import { TokenAmountInput } from "@/components/ui/token-amount-input/token-amount-input";
    import { cn } from "@/lib/utils";
    import { duration, ease, overlayVariants, spring } from "@/lib/motion";
    import { formatAmount, parseAmount } from "@/lib/web3/format";
    import { ChainIcon } from "@/lib/web3/icons";
    import { explorerLink, type Chain, type Token } from "@/lib/web3/types";
    
    export interface BridgeQuote {
      /** Amount received on the destination chain, in the token's smallest unit. */
      receive: bigint;
      /** Estimated transfer time in seconds. */
      etaSeconds: number;
      /** Fee lines, pre-formatted, e.g. { label: "Bridge fee", value: "0.0008 ETH" }. */
      fees: { label: string; value: string }[];
    }
    
    export type BridgeStatus = "idle" | "awaiting-signature" | "sending" | "bridging" | "complete" | "failed";
    
    export interface BridgeWidgetProps
      extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Chains the bridge connects. */
      chains: Chain[];
      /** Token being bridged. */
      token: Token;
      /** Token balance per chain id, in the token's smallest unit. */
      balances?: Record<number, bigint>;
      /** Initial source chain id. @default chains[0] */
      defaultFromChainId?: number;
      /** Initial destination chain id. @default chains[1] */
      defaultToChainId?: number;
      /** Initial amount text. @default "" */
      defaultAmount?: string;
      /** Returns a quote for a route and amount. */
      getQuote?: (from: Chain, to: Chain, amount: bigint) => BridgeQuote | null;
      /** Transfer lifecycle; anything but "idle" shows the progress tracker. @default "idle" */
      status?: BridgeStatus;
      /** Bridging progress from 0 to 1, shown while status is "bridging". */
      progress?: number;
      /** Called from the Bridge button. */
      onBridge?: (transfer: { from: Chain; to: Chain; amount: bigint; quote: BridgeQuote }) => void;
      /** Called from "Bridge more" or "Try again"; reset status to "idle" here. */
      onReset?: () => void;
      /** Source transaction hash, linked from the tracker. */
      txHash?: string;
      /** Reason shown when status is "failed". @default "The transfer didn't go through." */
      error?: string;
    }
    
    const formatEta = (s: number) => (s < 90 ? `~${s}s` : `~${Math.round(s / 60)} min`);
    
    function ChainPicker({ label, chain, chains, exclude, onPick }: { label: string; chain: Chain; chains: Chain[]; exclude: number; onPick: (id: number) => void }) {
      const reduceMotion = useReducedMotion() ?? false;
      return (
        <DropdownMenu.Root>
          <DropdownMenu.Trigger className="group flex min-w-0 flex-1 cursor-pointer flex-col items-center gap-2 rounded-2xl p-3 outline-none transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-ring">
            <ChainIcon chain={chain} size={40} />
            <span className="text-center">
              <span className="block text-[11px] text-muted-foreground">{label}</span>
              <span className="flex items-center gap-1 text-sm font-semibold text-foreground">
                {chain.shortName ?? chain.name}
                <ChevronDown className="size-3.5 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" aria-hidden="true" />
              </span>
            </span>
          </DropdownMenu.Trigger>
          <DropdownMenu.Portal>
            <DropdownMenu.Content sideOffset={6} collisionPadding={12} asChild>
              <motion.div
                variants={overlayVariants(reduceMotion)}
                initial="hidden"
                animate="visible"
                className="z-50 w-52 origin-[var(--radix-dropdown-menu-content-transform-origin)] rounded-xl border border-border bg-popover p-1.5 shadow-lg outline-none"
              >
                <DropdownMenu.RadioGroup value={String(chain.id)} onValueChange={(v) => onPick(Number(v))}>
                  {chains.map((c) => (
                    <DropdownMenu.RadioItem
                      key={c.id}
                      value={String(c.id)}
                      disabled={c.id === exclude}
                      className="flex h-10 cursor-pointer items-center gap-3 rounded-lg px-2.5 text-sm text-foreground outline-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-40 data-[highlighted]:bg-accent"
                    >
                      <ChainIcon chain={c} size={20} />
                      <span className="flex-1">{c.name}</span>
                      {c.id === chain.id ? <Check className="size-4" aria-hidden="true" /> : null}
                    </DropdownMenu.RadioItem>
                  ))}
                </DropdownMenu.RadioGroup>
              </motion.div>
            </DropdownMenu.Content>
          </DropdownMenu.Portal>
        </DropdownMenu.Root>
      );
    }
    
    export function BridgeWidget({
      chains,
      token,
      balances = {},
      defaultFromChainId,
      defaultToChainId,
      defaultAmount = "",
      getQuote,
      status = "idle",
      progress = 0,
      onBridge,
      onReset,
      txHash,
      error = "The transfer didn't go through.",
      className,
      ...props
    }: BridgeWidgetProps) {
      const reduceMotion = useReducedMotion() ?? false;
      const [fromId, setFromId] = useState(defaultFromChainId ?? chains[0]?.id);
      const [toId, setToId] = useState(defaultToChainId ?? chains[1]?.id);
      const [amount, setAmount] = useState(defaultAmount);
      const [turns, setTurns] = useState(0);
      const from = chains.find((c) => c.id === fromId) ?? chains[0];
      const to = chains.find((c) => c.id === toId) ?? chains[1];
      const parsed = parseAmount(amount, token.decimals);
      const balance = balances[from.id];
      const insufficient = parsed !== null && balance !== undefined && parsed > balance;
      const quote = parsed && parsed > BigInt(0) && getQuote ? getQuote(from, to, parsed) : null;
      const tracking = status !== "idle";
      const active = status === "bridging";
    
      const button = !parsed
        ? { label: "Enter an amount", disabled: true }
        : insufficient
          ? { label: `Insufficient ${token.symbol} on ${from.shortName ?? from.name}`, disabled: true, error: true }
          : !quote
            ? { label: "No route available", disabled: true }
            : { label: `Bridge to ${to.shortName ?? to.name}`, disabled: false };
    
      const steps = [
        { key: "awaiting-signature", label: "Confirm in your wallet" },
        { key: "sending", label: `Send on ${from.shortName ?? from.name}` },
        { key: "bridging", label: "Bridging" },
        { key: "complete", label: `Receive on ${to.shortName ?? to.name}` },
      ];
      const order = ["awaiting-signature", "sending", "bridging", "complete"];
      const [failedAt, setFailedAt] = useState(0);
      const [lastStatus, setLastStatus] = useState(status);
      if (status !== lastStatus) {
        if (status === "failed") setFailedAt(Math.max(0, order.indexOf(lastStatus)));
        setLastStatus(status);
      }
      const currentIndex = status === "failed" ? failedAt : status === "complete" ? order.length : order.indexOf(status);
      const href = txHash ? explorerLink(from.explorerUrl, "tx", txHash) : undefined;
    
      const swap = reduceMotion
        ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
        : {
            initial: { opacity: 0, y: 10, filter: "blur(4px)" },
            animate: { opacity: 1, y: 0, filter: "blur(0px)" },
            exit: { opacity: 0, y: -6, filter: "blur(4px)" },
          };
    
      return (
        <motion.div
          layout
          transition={{ layout: reduceMotion ? { duration: 0 } : spring.smooth }}
          style={{ borderRadius: 24 }}
          className={cn("w-full max-w-md overflow-hidden border border-border bg-background p-2 shadow-md", className)}
          {...props}
        >
          <div className="flex h-11 items-center px-3">
            <p className="text-sm font-semibold text-foreground">Bridge {token.symbol}</p>
          </div>
    
          <div className="flex items-center gap-1 rounded-2xl border border-border bg-background px-1">
            <ChainPicker label="From" chain={from} chains={chains} exclude={to.id} onPick={setFromId} />
            <div className="relative flex w-24 shrink-0 items-center justify-center self-stretch" aria-hidden={tracking}>
              <svg viewBox="0 0 96 40" className="absolute inset-x-0 top-1/2 h-10 w-full -translate-y-1/2" aria-hidden="true">
                <path d="M4 26 C 30 4, 66 4, 92 26" fill="none" stroke="var(--border)" strokeWidth="2" strokeLinecap="round" />
                <motion.path
                  d="M4 26 C 30 4, 66 4, 92 26"
                  fill="none"
                  stroke={active ? "var(--primary)" : "var(--muted-foreground)"}
                  strokeOpacity={active ? 1 : 0.55}
                  strokeWidth="2"
                  strokeLinecap="round"
                  strokeDasharray="3 9"
                  animate={{ strokeDashoffset: [0, -24] }}
                  transition={{ duration: active ? 0.5 : 1.4, ease: "linear", repeat: reduceMotion ? 0 : Infinity }}
                />
              </svg>
              <motion.button
                type="button"
                onClick={() => {
                  setTurns(turns + 1);
                  setFromId(to.id);
                  setToId(from.id);
                }}
                disabled={tracking}
                aria-label={`Swap direction: bridge from ${to.name} to ${from.name}`}
                animate={{ rotate: turns * 180 }}
                transition={reduceMotion ? { duration: 0 } : spring.snappy}
                className="after:absolute after:-inset-0.5 relative z-10 mt-6 grid size-9 cursor-pointer place-items-center rounded-full border border-border bg-background text-foreground shadow-sm outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
              >
                <ArrowLeftRight className="size-4" aria-hidden="true" />
              </motion.button>
            </div>
            <ChainPicker label="To" chain={to} chains={chains} exclude={from.id} onPick={setToId} />
          </div>
    
          <AnimatePresence mode="popLayout" initial={false}>
            {!tracking ? (
              <motion.div key="form" variants={swap} initial="initial" animate="animate" exit="exit" transition={{ duration: duration.base, ease: ease.out }}>
                <TokenAmountInput
                  className="mt-1.5"
                  label={`Send from ${from.shortName ?? from.name}`}
                  token={token}
                  balance={balance}
                  value={amount}
                  onValueChange={(text) => setAmount(text)}
                />
                {quote ? (
                  <div className="mt-2 rounded-2xl bg-surface p-3.5 text-[13px]">
                    <div className="flex items-center justify-between gap-3">
                      <span className="text-muted-foreground">You receive</span>
                      <span className="font-mono font-semibold text-foreground tabular-nums">
                        ≈ {formatAmount(quote.receive, token.decimals, { maxFractionDigits: 6 })} {token.symbol}
                      </span>
                    </div>
                    <div className="mt-1.5 flex items-center justify-between gap-3">
                      <span className="text-muted-foreground">Arrives in</span>
                      <span className="flex items-center gap-1 font-mono text-foreground tabular-nums">
                        <Clock3 className="size-3.5 text-muted-foreground" aria-hidden="true" />
                        {formatEta(quote.etaSeconds)}
                      </span>
                    </div>
                    <dl className="mt-2.5 space-y-1 border-t border-border pt-2.5 text-xs">
                      {quote.fees.map((fee) => (
                        <div key={fee.label} className="flex justify-between gap-3">
                          <dt className="text-muted-foreground">{fee.label}</dt>
                          <dd className="font-mono text-foreground tabular-nums">{fee.value}</dd>
                        </div>
                      ))}
                    </dl>
                  </div>
                ) : null}
                <button
                  type="button"
                  disabled={button.disabled}
                  onClick={() => quote && parsed && onBridge?.({ from, to, amount: parsed, quote })}
                  className={cn(
                    "mt-2 flex h-13 w-full cursor-pointer items-center justify-center rounded-2xl text-[15px] font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed",
                    button.error
                      ? "bg-destructive/10 text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]"
                      : button.disabled
                        ? "bg-muted text-muted-foreground"
                        : "bg-foreground text-background hover:bg-foreground/90",
                  )}
                >
                  {button.label}
                </button>
              </motion.div>
            ) : (
              <motion.div key="tracker" variants={swap} initial="initial" animate="animate" exit="exit" transition={{ duration: duration.base, ease: ease.out }} className="px-3 pt-4 pb-2">
                <p className="text-sm font-semibold text-foreground">
                  {status === "complete" ? "Transfer complete" : status === "failed" ? "Transfer failed" : `Bridging ${parsed ? formatAmount(parsed, token.decimals) : ""} ${token.symbol}`}
                </p>
                <ol className="mt-4 space-y-0">
                  {steps.map((step, index) => {
                    const done = index < currentIndex;
                    const current = index === currentIndex && status !== "complete";
                    const failed = current && status === "failed";
                    return (
                      <li key={step.key} className="relative flex gap-3 pb-5 last:pb-0">
                        {index < steps.length - 1 ? (
                          <span className="absolute top-7 bottom-0 left-[13px] w-0.5 overflow-hidden rounded-full bg-border" aria-hidden="true">
                            <motion.span
                              className="absolute inset-0 origin-top bg-success"
                              initial={false}
                              animate={{ scaleY: done ? 1 : 0 }}
                              transition={{ duration: reduceMotion ? 0 : 0.4, ease: ease.out }}
                            />
                          </span>
                        ) : null}
                        <span
                          className={cn(
                            "relative grid size-7 shrink-0 place-items-center rounded-full border-2 transition-colors",
                            done && "border-success bg-success text-success-foreground",
                            failed && "border-destructive bg-destructive text-white",
                            current && !failed && "border-primary text-link",
                            !done && !current && "border-border text-muted-foreground",
                          )}
                          aria-hidden="true"
                        >
                          {done ? (
                            <Check className="size-3.5" strokeWidth={3} />
                          ) : failed ? (
                            <X className="size-3.5" strokeWidth={3} />
                          ) : current ? (
                            <svg className="size-3.5 animate-spin motion-reduce:animate-pulse" viewBox="0 0 24 24" fill="none">
                              <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" />
                            </svg>
                          ) : (
                            <span className="font-mono text-[11px]">{index + 1}</span>
                          )}
                        </span>
                        <div className="min-w-0 flex-1 pt-0.5">
                          <p className={cn("text-sm", done || current ? "font-medium text-foreground" : "text-muted-foreground")}>
                            {step.label}
                            <span className="sr-only">{done ? ", done" : failed ? ", failed" : current ? ", in progress" : ", waiting"}</span>
                          </p>
                          {step.key === "bridging" && current && !failed ? (
                            <div className="mt-2">
                              <div className="h-1.5 overflow-hidden rounded-full bg-muted">
                                <div
                                  className="h-full origin-left rounded-full bg-primary transition-transform duration-500 ease-out motion-reduce:transition-none"
                                  style={{ transform: `scaleX(${Math.min(1, Math.max(0.02, progress))})` }}
                                />
                              </div>
                              <p className="mt-1 font-mono text-xs text-muted-foreground tabular-nums">
                                {Math.round(progress * 100)}%{quote ? ` · ${formatEta(Math.max(5, Math.round(quote.etaSeconds * (1 - progress))))} left` : ""}
                              </p>
                            </div>
                          ) : null}
                          {failed ? <p className="mt-1 text-xs text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]">{error}</p> : null}
                        </div>
                      </li>
                    );
                  })}
                </ol>
                <div className="mt-4 flex items-center justify-between gap-3">
                  {href ? (
                    <a
                      href={href}
                      target="_blank"
                      rel="noreferrer"
                      className="inline-flex items-center gap-1 rounded text-[13px] font-medium text-foreground underline decoration-border underline-offset-4 outline-none hover:decoration-foreground focus-visible:ring-2 focus-visible:ring-ring"
                    >
                      View source transaction
                      <ArrowUpRight className="size-3.5" aria-hidden="true" />
                    </a>
                  ) : (
                    <span />
                  )}
                  {status === "complete" || status === "failed" ? (
                    <button
                      type="button"
                      onClick={() => {
                        if (status === "complete") setAmount("");
                        onReset?.();
                      }}
                      className="relative after:absolute after:inset-x-0 after:-inset-y-0.5 inline-flex h-9 cursor-pointer items-center rounded-lg bg-foreground px-3.5 text-[13px] font-medium text-background outline-none hover:bg-foreground/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
                    >
                      {status === "complete" ? "Bridge more" : "Try again"}
                    </button>
                  ) : null}
                </div>
              </motion.div>
            )}
          </AnimatePresence>
          <span className="sr-only" role="status" aria-live="polite">
            {tracking ? (status === "complete" ? "Transfer complete" : status === "failed" ? `Transfer failed. ${error}` : steps[currentIndex]?.label ?? "") : ""}
          </span>
        </motion.div>
      );
    }

Usage

import { BridgeWidget } from "@/components/ui/bridge-widget/bridge-widget";

<BridgeWidget
  chains={chains}
  token={{ symbol: "ETH", decimals: 18 }}
  balances={{ 1: ethOnMainnet, 8453: ethOnBase }}
  getQuote={(from, to, amount) => bridge.quote(from.id, to.id, amount)} // { receive, etaSeconds, fees }
  status={status} // "idle" → "awaiting-signature" → "sending" → "bridging" → "complete" | "failed"
  progress={confirmations / required}
  onBridge={({ from, to, amount }) => send(from, to, amount)}
  onReset={() => setStatus("idle")}
  txHash={sourceHash}
/>

API reference

PropTypeDefaultDescription
chains*Chain[]–Chains the bridge connects.
token*Token–Token being bridged.
balancesRecord<number, bigint>–Token balance per chain id, in the token's smallest unit.
defaultFromChainIdnumberchains[0]Initial source chain id.
defaultToChainIdnumberchains[1]Initial destination chain id.
defaultAmountstring""Initial amount text.
getQuote(from: Chain, to: Chain, amount: bigint) => BridgeQuote | null–Returns a quote for a route and amount.
statusBridgeStatus"idle"Transfer lifecycle; anything but "idle" shows the progress tracker.
progressnumber–Bridging progress from 0 to 1, shown while status is "bridging".
onBridge(transfer: { from: Chain; to: Chain; amount: bigint; quote: BridgeQuote }) => void–Called from the Bridge button.
onReset() => void–Called from "Bridge more" or "Try again"; reset status to "idle" here.
txHashstring–Source transaction hash, linked from the tracker.
errorstring"The transfer didn't go through."Reason shown when status is "failed".

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

  • Amounts and the received estimate are bigint. Fee lines are pre-formatted strings, so you can show them in any unit or currency.
  • Reuses Token Amount Input for the amount and balance check, so it installs with it.

Accessibility

  • Chain pickers are Radix dropdown menus with radio items; the chain already used on the other side is disabled.
  • The swap-direction button is named after the result ("Swap direction: bridge from Base to Ethereum").
  • The tracker is an ordered list: each step has "done", "in progress", "failed" or "waiting" in screen-reader text, and step changes are announced in a live region.
  • The form morphs into the tracker with a height change. Reduced motion uses fades and stops the flowing route dashes.