Skip to content

Transaction Review Modal

Summary, fees and total, confirmed with a hold-to-confirm button.

Installation

CLI

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

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion radix-ui tailwind-merge viem
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/color.ts
    export type Vec3 = [r: number, g: number, b: number];
    
    /**
     * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels.
     * Anything else falls back to black, so a typo never crashes a shader.
     */
    export function hexToRgb(hex: string): Vec3 {
      let value = hex.trim().replace(/^#/, "");
      if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join("");
      if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0];
      const n = Number.parseInt(value.slice(0, 6), 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }
    
    /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function hexToVec3(hex: string): Vec3 {
      const [r, g, b] = hexToRgb(hex);
      return [r / 255, g / 255, b / 255];
    }
    
    /**
     * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as
     * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables
     * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result
     * instead of calling this every frame. Browser only; unreadable values fall back to black.
     */
    export function cssColorToRgb(color: string, element: Element): Vec3 {
      const value = color.trim();
      if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value);
      // Children of a canvas still get computed styles, but a regular parent is the safer host.
      const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element;
      const probe = document.createElement("span");
      probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden";
      probe.style.color = value;
      host.appendChild(probe);
      const computed = getComputedStyle(probe).color;
      probe.remove();
      // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes.
      const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
      if (!ctx) return [0, 0, 0];
      ctx.fillStyle = "#000";
      ctx.fillStyle = computed;
      ctx.fillRect(0, 0, 1, 1);
      const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
      return [r, g, b];
    }
    
    /**
     * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or
     * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme.
     * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe.
     */
    export function observeTheme(onChange: () => void): () => void {
      const observer = new MutationObserver(onChange);
      const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
      observer.observe(document.documentElement, options);
      if (document.body) observer.observe(document.body, options);
      const scheme = window.matchMedia("(prefers-color-scheme: dark)");
      scheme.addEventListener("change", onChange);
      return () => {
        observer.disconnect();
        scheme.removeEventListener("change", onChange);
      };
    }
    
    /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function cssColorToVec3(color: string, element: Element): Vec3 {
      const [r, g, b] = cssColorToRgb(color, element);
      return [r / 255, g / 255, b / 255];
    }
    
    const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
    const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
    
    /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */
    export function rgbToOklch([r, g, b]: Vec3): Vec3 {
      const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255));
      const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
      const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
      const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
      const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
      const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
      const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
      return [L, Math.hypot(A, B), Math.atan2(B, A)];
    }
    
    /** OKLCH back to 0–255 sRGB, clamped into gamut. */
    export function oklchToRgb([L, C, h]: Vec3): Vec3 {
      const A = C * Math.cos(h);
      const B = C * Math.sin(h);
      const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
      const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
      const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
      const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
      return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3;
    }
    
    /**
     * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep
     * their chroma instead of turning muddy the way an RGB mix does.
     */
    export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 {
      const [L1, C1, h1] = rgbToOklch(a);
      const [L2, C2, h2] = rgbToOklch(b);
      // A near-gray end has no meaningful hue: borrow the other end's.
      const ha = C1 < 0.02 ? h2 : h1;
      const hb = C2 < 0.02 ? h1 : h2;
      let dh = hb - ha;
      if (dh > Math.PI) dh -= Math.PI * 2;
      if (dh < -Math.PI) dh += Math.PI * 2;
      return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]);
    }
    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
    lib/web3/format.ts
    import { formatUnits, getAddress, isAddress, parseUnits } from "viem";
    
    /**
     * Formatting for on-chain values. Amounts are always `bigint` plus `decimals`: nothing here
     * converts a balance to a float, so large or precise values never lose digits.
     */
    
    /** Decimals used for USD values and prices across the web3 components (like Chainlink feeds). */
    export const USD_DECIMALS = 8;
    
    const COMPACT_UNITS = [
      { suffix: "T", exponent: 12 },
      { suffix: "B", exponent: 9 },
      { suffix: "M", exponent: 6 },
      { suffix: "K", exponent: 3 },
    ] as const;
    
    function groupThousands(integer: string) {
      return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
    }
    
    function compact(value: bigint, decimals: number): string | null {
      const base = BigInt(10) ** BigInt(decimals);
      for (const { suffix, exponent } of COMPACT_UNITS) {
        const unit = base * BigInt(10) ** BigInt(exponent);
        if (value < unit) continue;
        const tenths = (value * BigInt(10)) / unit;
        const whole = tenths / BigInt(10);
        const fraction = tenths % BigInt(10);
        return `${groupThousands(whole.toString())}${fraction && whole < BigInt(100) ? `.${fraction}` : ""}${suffix}`;
      }
      return null;
    }
    
    export interface FormatAmountOptions {
      /** Most fraction digits shown; extra digits are cut, never rounded up. @default 4 */
      maxFractionDigits?: number;
      /** Fraction digits always shown (padded with zeros). @default 0 */
      minFractionDigits?: number;
      /** Abbreviate values of 1,000 and up: 1.2K, 3.4M, 5B. @default false */
      compact?: boolean;
      /** Prefix positive values with "+". @default false */
      signed?: boolean;
    }
    
    /**
     * Formats a token amount: `formatAmount(parseUnits("1.23456789", 18), 18)` → "1.2345".
     * Digits past `maxFractionDigits` are truncated so a balance is never overstated, and a tiny
     * non-zero amount shows as "<0.0001" instead of "0".
     */
    export function formatAmount(value: bigint, decimals: number, options: FormatAmountOptions = {}) {
      const { maxFractionDigits = 4, minFractionDigits = 0, compact: useCompact = false, signed = false } = options;
      const negative = value < BigInt(0);
      const abs = negative ? -value : value;
      const sign = negative ? "-" : signed && abs > BigInt(0) ? "+" : "";
    
      const short = useCompact ? compact(abs, decimals) : null;
      if (short) return sign + short;
    
      const [integer, fraction = ""] = formatUnits(abs, decimals).split(".");
      let digits = fraction.slice(0, maxFractionDigits).replace(/0+$/, "");
      if (digits.length < minFractionDigits) digits = digits.padEnd(minFractionDigits, "0");
    
      if (abs > BigInt(0) && integer === "0" && !/[1-9]/.test(digits)) {
        return `${sign}<0.${"0".repeat(Math.max(0, maxFractionDigits - 1))}1`;
      }
      return `${sign}${groupThousands(integer)}${digits ? `.${digits}` : ""}`;
    }
    
    /** USD value of a token amount, as a bigint with `USD_DECIMALS` decimals. `price` uses the same decimals. */
    export function toUsd(amount: bigint, tokenDecimals: number, price: bigint) {
      return (amount * price) / BigInt(10) ** BigInt(tokenDecimals);
    }
    
    /** Formats a USD bigint (default `USD_DECIMALS`): "$1,234.56", or "$1.2M" with `compact`. */
    export function formatUsd(value: bigint, decimals = USD_DECIMALS, options: Omit<FormatAmountOptions, "maxFractionDigits"> = {}) {
      const { compact: useCompact = false, signed = false } = options;
      const negative = value < BigInt(0);
      const abs = negative ? -value : value;
      const sign = negative ? "-" : signed && abs > BigInt(0) ? "+" : "";
      // Under a cent, keep up to 6 significant fraction digits so token prices like $0.000042 stay readable.
      const tiny = abs > BigInt(0) && abs < BigInt(10) ** BigInt(Math.max(0, decimals - 2));
      const body = formatAmount(abs, decimals, {
        compact: useCompact,
        maxFractionDigits: tiny ? Math.min(decimals, 8) : 2,
        minFractionDigits: tiny ? 0 : 2,
      });
      return `${sign}$${body}`;
    }
    
    /** Formats a ratio as a percentage: `formatPercent(0.0123)` → "1.23%". */
    export function formatPercent(ratio: number, options: { fractionDigits?: number; signed?: boolean } = {}) {
      const { fractionDigits = 2, signed = false } = options;
      if (!Number.isFinite(ratio)) return "—";
      const text = `${(Math.abs(ratio) * 100).toFixed(fractionDigits)}%`;
      if (ratio < 0) return `-${text}`;
      return signed && ratio > 0 ? `+${text}` : text;
    }
    
    /** Formats basis points (1 bp = 0.01%): `formatBps(BigInt(50))` → "0.5%". */
    export function formatBps(bps: bigint | number) {
      const value = BigInt(bps);
      const abs = value < BigInt(0) ? -value : value;
      const fraction = abs % BigInt(100);
      return `${value < BigInt(0) ? "-" : ""}${abs / BigInt(100)}${fraction ? `.${fraction.toString().padStart(2, "0").replace(/0$/, "")}` : ""}%`;
    }
    
    /** Compact display for plain counts and non-token numbers: 1200 → "1.2K". */
    export function compactNumber(value: number | bigint) {
      return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(value);
    }
    
    /** EIP-55 checksummed address, or the input unchanged when it isn't a valid address. */
    export function checksumAddress(address: string) {
      return isAddress(address, { strict: false }) ? getAddress(address) : address;
    }
    
    /** "0x1234…abcd": checksummed, with `start` characters (including 0x) and `end` characters kept. */
    export function truncateAddress(address: string, { start = 6, end = 4 }: { start?: number; end?: number } = {}) {
      const value = checksumAddress(address);
      if (value.length <= start + end + 1) return value;
      return `${value.slice(0, start)}…${value.slice(-end)}`;
    }
    
    /**
     * Parses user input into a bigint amount. Accepts digits with one "." (commas are ignored),
     * cuts fraction digits past `decimals`, and returns null for anything that isn't a number.
     */
    export function parseAmount(input: string, decimals: number): bigint | null {
      const text = input.replace(/,/g, "").trim();
      if (!/^\d*\.?\d*$/.test(text) || text === "" || text === ".") return null;
      const [integer, fraction = ""] = text.split(".");
      return parseUnits(`${integer || "0"}.${fraction.slice(0, decimals) || "0"}`, decimals);
    }
    
    /** A percentage of an amount, in whole percent: `percentOf(balance, 25)`. Rounds down. */
    export function percentOf(amount: bigint, percent: number) {
      return (amount * BigInt(Math.round(percent * 100))) / BigInt(10_000);
    }
    lib/web3/icons.tsx
    "use client";
    
    import { useId, type ComponentProps } from "react";
    import { hexToRgb } from "@/lib/color";
    import { cn } from "@/lib/utils";
    
    /**
     * Generated avatars for tokens, chains and accounts. No third-party logos are bundled:
     * pass `iconUrl` (tokens, chains) or `src` (accounts) to show your own artwork.
     */
    
    const PALETTE = ["#6366F1", "#2563EB", "#0EA5E9", "#14B8A6", "#16A34A", "#F59E0B", "#EF4444", "#EC4899", "#8B5CF6", "#64748B"];
    
    function hashString(value: string) {
      let hash = 0x811c9dc5;
      for (const char of value) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193);
      return hash >>> 0;
    }
    
    /** Text color with enough contrast on a fill (relative luminance threshold). */
    function inkFor(fill: string) {
      const [r, g, b] = hexToRgb(fill).map((c) => {
        const s = c / 255;
        return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
      });
      return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.45 ? "#09090B" : "#FFFFFF";
    }
    
    function Monogram({ text, fill, size, shape, className }: { text: string; fill: string; size: number; shape: "circle" | "squircle"; className?: string }) {
      const letters = text.slice(0, 4);
      const fontSize = letters.length <= 2 ? 13 : letters.length === 3 ? 10 : 8.5;
      return (
        <svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" className={cn("shrink-0", className)}>
          {shape === "circle" ? <circle cx="16" cy="16" r="16" fill={fill} /> : <rect width="32" height="32" rx="9" fill={fill} />}
          <text
            x="16"
            y="16"
            dy="0.35em"
            textAnchor="middle"
            fontSize={fontSize}
            fontWeight={700}
            fill={inkFor(fill)}
            style={{ fontFamily: "inherit", letterSpacing: letters.length > 2 ? "-0.02em" : undefined }}
          >
            {letters}
          </text>
        </svg>
      );
    }
    
    function ImageIcon({ src, size, rounded, className }: { src: string; size: number; rounded: string; className?: string }) {
      // eslint-disable-next-line @next/next/no-img-element -- consumers pass arbitrary logo URLs
      return <img src={src} alt="" width={size} height={size} className={cn("shrink-0 object-cover", rounded, className)} />;
    }
    
    export interface TokenIconProps {
      token: { symbol: string; iconUrl?: string; color?: string };
      /** Pixel size. @default 24 */
      size?: number;
      className?: string;
    }
    
    /** A token's logo, or a colored circle with its symbol. Decorative: pair it with visible text. */
    export function TokenIcon({ token, size = 24, className }: TokenIconProps) {
      if (token.iconUrl) return <ImageIcon src={token.iconUrl} size={size} rounded="rounded-full" className={className} />;
      const fill = token.color ?? PALETTE[hashString(token.symbol) % PALETTE.length];
      return <Monogram text={token.symbol} fill={fill} size={size} shape="circle" className={className} />;
    }
    
    export interface ChainIconProps {
      chain: { name: string; shortName?: string; iconUrl?: string; color?: string };
      /** Pixel size. @default 20 */
      size?: number;
      className?: string;
    }
    
    /** A chain's logo, or a rounded square with its initial. Decorative: pair it with visible text. */
    export function ChainIcon({ chain, size = 20, className }: ChainIconProps) {
      if (chain.iconUrl) return <ImageIcon src={chain.iconUrl} size={size} rounded="rounded-[28%]" className={className} />;
      const fill = chain.color ?? PALETTE[hashString(chain.name) % PALETTE.length];
      return <Monogram text={(chain.shortName ?? chain.name).charAt(0)} fill={fill} size={size} shape="squircle" className={className} />;
    }
    
    export interface WalletIconProps {
      wallet: { name: string; iconUrl?: string; color?: string };
      /** Pixel size. @default 32 */
      size?: number;
      className?: string;
    }
    
    /** A wallet's logo, or a rounded square with its initial. Decorative: pair it with visible text. */
    export function WalletIcon({ wallet, size = 32, className }: WalletIconProps) {
      if (wallet.iconUrl) return <ImageIcon src={wallet.iconUrl} size={size} rounded="rounded-[28%]" className={className} />;
      const fill = wallet.color ?? PALETTE[hashString(wallet.name) % PALETTE.length];
      return <Monogram text={wallet.name.charAt(0)} fill={fill} size={size} shape="squircle" className={className} />;
    }
    
    export interface AddressAvatarProps extends Omit<ComponentProps<"svg">, "children"> {
      address: string;
      /** Pixel size. @default 24 */
      size?: number;
    }
    
    /** A deterministic gradient avatar for an address: same address, same avatar, on every device. */
    export function AddressAvatar({ address, size = 24, className, ...props }: AddressAvatarProps) {
      const id = useId();
      const hex = address.toLowerCase().replace(/^0x/, "").padEnd(40, "0");
      const byte = (i: number) => Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) || 0;
      const hue = Math.round((byte(0) / 255) * 360);
      const hue2 = (hue + 40 + Math.round((byte(1) / 255) * 120)) % 360;
      const angle = Math.round((byte(2) / 255) * 360);
      return (
        <svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" className={cn("shrink-0 rounded-full", className)} {...props}>
          <defs>
            <linearGradient id={`${id}g`} gradientTransform={`rotate(${angle} 0.5 0.5)`}>
              <stop offset="0" stopColor={`hsl(${hue} 85% 64%)`} />
              <stop offset="1" stopColor={`hsl(${hue2} 80% 52%)`} />
            </linearGradient>
            <clipPath id={`${id}c`}>
              <circle cx="16" cy="16" r="16" />
            </clipPath>
          </defs>
          <g clipPath={`url(#${id}c)`}>
            <rect width="32" height="32" fill={`url(#${id}g)`} />
            <circle cx={6 + (byte(3) % 20)} cy={6 + (byte(4) % 20)} r={6 + (byte(5) % 6)} fill="#fff" opacity="0.32" />
            <circle cx={6 + (byte(6) % 20)} cy={6 + (byte(7) % 20)} r={3 + (byte(8) % 5)} fill="#fff" opacity="0.22" />
          </g>
        </svg>
      );
    }
    lib/web3/types.ts
    /** Shapes the web3 components accept. Your own token and chain objects only need these fields. */
    
    export interface Token {
      symbol: string;
      name?: string;
      /** Token decimals: 18 for ETH, 6 for USDC. Amounts are bigint in these units. */
      decimals: number;
      address?: string;
      /** Logo URL. Without one, a generated avatar (colored circle and symbol) is shown. */
      iconUrl?: string;
      /** Avatar color for the generated icon. Picked from the symbol when omitted. */
      color?: string;
      /** USD price as a bigint with USD_DECIMALS (8) decimals. */
      price?: bigint;
    }
    
    export interface Chain {
      id: number;
      name: string;
      shortName?: string;
      iconUrl?: string;
      color?: string;
      /** Block explorer base URL without a trailing slash, e.g. https://etherscan.io */
      explorerUrl?: string;
      nativeSymbol?: string;
    }
    
    export interface WalletOption {
      id: string;
      name: string;
      /** Logo URL. Without one, a generated squircle with the wallet's initial is shown. */
      iconUrl?: string;
      color?: string;
      /** Shows a "Recent" badge. */
      recent?: boolean;
      /** The wallet is detected in this browser. */
      installed?: boolean;
      /** Connects by scanning a QR code with a phone instead of a browser extension. */
      qr?: boolean;
      /** Short helper line under the name. */
      description?: string;
    }
    
    /** Lifecycle of anything the user signs: every state gets its own visual. */
    export type TxStatus = "idle" | "awaiting-signature" | "pending" | "confirmed" | "failed";
    
    /** Explorer URL for an address or transaction, or undefined without an explorer. */
    export function explorerLink(explorerUrl: string | undefined, kind: "address" | "tx", value: string) {
      return explorerUrl ? `${explorerUrl.replace(/\/$/, "")}/${kind}/${value}` : undefined;
    }
  3. Add the theme tokens

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

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

    components/ui/transaction-review-modal/transaction-review-modal.tsx
    "use client";
    
    import { useState, type ReactNode } from "react";
    import { Dialog } from "radix-ui";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { AlertTriangle, ArrowDown, ArrowUpRight, X } from "lucide-react";
    import { HoldToConfirmButton } from "@/components/ui/hold-to-confirm-button/hold-to-confirm-button";
    import { cn } from "@/lib/utils";
    import { duration, ease, overlayVariants, spring } from "@/lib/motion";
    import { formatAmount, formatUsd, USD_DECIMALS } from "@/lib/web3/format";
    import { TokenIcon } from "@/lib/web3/icons";
    import { explorerLink, type Token, type TxStatus } from "@/lib/web3/types";
    
    export interface ReviewAsset {
      /** e.g. "You pay", "You receive". */
      label: string;
      token: Token;
      /** Amount in the token's smallest unit. */
      amount: bigint;
      /** USD value as a bigint with 8 decimals. */
      usd?: bigint;
    }
    
    export interface ReviewDetail {
      label: string;
      value: ReactNode;
      /** Bold the row, e.g. for the total. */
      emphasis?: boolean;
    }
    
    export interface TransactionReviewModalProps {
      /** 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;
      /** Dialog title on the review step. @default "Review transaction" */
      title?: string;
      /** Assets moving, shown top to bottom with an arrow between them. */
      assets?: ReviewAsset[];
      /** Rows such as network fee and total. */
      details?: ReviewDetail[];
      /** A warning shown above the confirm button, e.g. high price impact. */
      warning?: ReactNode;
      /** Transaction lifecycle. "idle" is the review step. @default "idle" */
      status?: TxStatus;
      /** Called when the hold-to-confirm completes. Request the signature here. */
      onConfirm: () => void;
      /** Label of the hold button. @default "Hold to confirm" */
      confirmLabel?: string;
      /** Milliseconds to hold the button. @default 1200 */
      holdDuration?: number;
      /** Hash of the submitted transaction, linked from the pending and done states. */
      txHash?: string;
      /** Block explorer base URL. */
      explorerUrl?: string;
      /** Reason shown in the failed state. @default "The transaction was rejected or reverted." */
      error?: string;
      /** Called from "Try again" in the failed state. */
      onRetry?: () => void;
      /** Heading of the confirmed state. @default "Transaction confirmed" */
      successTitle?: string;
    }
    
    function Ring({ tone }: { tone: "muted" | "primary" }) {
      return (
        <svg viewBox="0 0 64 64" className="size-16" aria-hidden="true">
          <circle cx="32" cy="32" r="26" fill="none" stroke="var(--border)" strokeWidth="5" />
          <g className="origin-center animate-spin [transform-box:fill-box] motion-reduce:animate-pulse">
            <circle
              cx="32"
              cy="32"
              r="26"
              fill="none"
              stroke={tone === "primary" ? "var(--primary)" : "var(--muted-foreground)"}
              strokeWidth="5"
              strokeLinecap="round"
              pathLength={1}
              strokeDasharray="0.25 1"
            />
          </g>
        </svg>
      );
    }
    
    function Outcome({ ok, reduceMotion }: { ok: boolean; reduceMotion: boolean }) {
      return (
        <motion.svg
          viewBox="0 0 64 64"
          className="size-16"
          aria-hidden="true"
          initial={reduceMotion ? { opacity: 0 } : { scale: 0.6, opacity: 0 }}
          animate={{ scale: 1, opacity: 1 }}
          transition={reduceMotion ? { duration: 0.15 } : spring.bouncy}
        >
          <circle cx="32" cy="32" r="30" fill={ok ? "var(--success)" : "var(--destructive)"} />
          {ok ? (
            <motion.path
              d="M20 33l8 8 16-18"
              fill="none"
              stroke="#fff"
              strokeWidth="5"
              strokeLinecap="round"
              strokeLinejoin="round"
              initial={{ pathLength: reduceMotion ? 1 : 0 }}
              animate={{ pathLength: 1 }}
              transition={{ duration: 0.4, delay: 0.15, ease: "easeOut" }}
            />
          ) : (
            <path d="M23 23l18 18M41 23L23 41" stroke="#fff" strokeWidth="5" strokeLinecap="round" />
          )}
        </motion.svg>
      );
    }
    
    const ghostButton =
      "inline-flex h-10 flex-1 cursor-pointer items-center justify-center gap-2 rounded-xl border border-border bg-background px-4 text-sm font-medium text-foreground shadow-xs outline-none transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-ring";
    const solidButton =
      "inline-flex h-10 flex-1 cursor-pointer items-center justify-center gap-2 rounded-xl bg-foreground px-4 text-sm font-medium text-background outline-none transition-colors hover:bg-foreground/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2";
    
    export function TransactionReviewModal({
      open,
      defaultOpen = false,
      onOpenChange,
      trigger,
      title = "Review transaction",
      assets = [],
      details = [],
      warning,
      status = "idle",
      onConfirm,
      confirmLabel = "Hold to confirm",
      holdDuration = 1200,
      txHash,
      explorerUrl,
      error = "The transaction was rejected or reverted.",
      onRetry,
      successTitle = "Transaction confirmed",
    }: TransactionReviewModalProps) {
      const reduceMotion = useReducedMotion() ?? false;
      const [internalOpen, setInternalOpen] = useState(defaultOpen);
      const isOpen = open ?? internalOpen;
      const setOpen = (next: boolean) => {
        if (open === undefined) setInternalOpen(next);
        onOpenChange?.(next);
      };
      const href = txHash ? explorerLink(explorerUrl, "tx", txHash) : undefined;
    
      const heading = {
        idle: title,
        "awaiting-signature": "Confirm in your wallet",
        pending: "Transaction submitted",
        confirmed: successTitle,
        failed: "Transaction failed",
      }[status];
    
      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)" },
          };
    
      const explorer = 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 on explorer
          <ArrowUpRight className="size-3.5" aria-hidden="true" />
        </a>
      ) : null;
    
      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 relative w-full max-w-sm overflow-hidden border border-border bg-popover text-popover-foreground shadow-xl outline-none"
                    >
                      <Dialog.Close
                        aria-label="Close"
                        className="absolute top-3 right-3 z-10 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>
                      <AnimatePresence mode="popLayout" initial={false}>
                        <motion.div
                          key={status}
                          variants={swap}
                          initial="initial"
                          animate="animate"
                          exit="exit"
                          transition={{ duration: duration.base, ease: ease.out }}
                          className="p-5"
                        >
                          {status === "idle" ? (
                            <>
                              <Dialog.Title className="pr-10 text-base font-semibold tracking-tight text-foreground">{heading}</Dialog.Title>
                              {assets.length ? (
                                <div className="mt-4 space-y-1">
                                  {assets.map((asset, index) => (
                                    <div key={asset.label}>
                                      {index > 0 ? (
                                        <div className="relative z-10 -my-3 flex justify-center">
                                          <span className="grid size-7 place-items-center rounded-lg border border-border bg-background text-muted-foreground">
                                            <ArrowDown className="size-3.5" aria-hidden="true" />
                                          </span>
                                        </div>
                                      ) : null}
                                      <div className="rounded-xl bg-surface p-3.5">
                                        <p className="text-xs text-muted-foreground">{asset.label}</p>
                                        <div className="mt-1 flex items-center gap-2.5">
                                          <TokenIcon token={asset.token} size={28} />
                                          <p className="min-w-0 flex-1 truncate font-mono text-xl font-semibold tracking-tight text-foreground tabular-nums">
                                            {formatAmount(asset.amount, asset.token.decimals, { maxFractionDigits: 6 })}{" "}
                                            <span className="font-sans text-base font-medium text-muted-foreground">{asset.token.symbol}</span>
                                          </p>
                                        </div>
                                        {asset.usd !== undefined ? (
                                          <p className="mt-0.5 font-mono text-xs text-muted-foreground tabular-nums">{formatUsd(asset.usd, USD_DECIMALS)}</p>
                                        ) : null}
                                      </div>
                                    </div>
                                  ))}
                                </div>
                              ) : null}
                              {details.length ? (
                                <dl className="mt-4 space-y-2 text-[13px]">
                                  {details.map((detail) => (
                                    <div key={detail.label} className={cn("flex items-center justify-between gap-4", detail.emphasis && "border-t border-border pt-2")}>
                                      <dt className="text-muted-foreground">{detail.label}</dt>
                                      <dd className={cn("text-right font-mono text-foreground tabular-nums", detail.emphasis && "font-semibold")}>{detail.value}</dd>
                                    </div>
                                  ))}
                                </dl>
                              ) : null}
                              {warning ? (
                                <div role="alert" className="mt-4 flex gap-2.5 rounded-xl border border-warning/35 bg-warning/8 p-3 text-[13px] leading-5 text-foreground">
                                  <AlertTriangle className="mt-0.5 size-4 shrink-0 text-[color-mix(in_oklab,var(--warning)_55%,var(--foreground))]" aria-hidden="true" />
                                  <div>{warning}</div>
                                </div>
                              ) : null}
                              <HoldToConfirmButton
                                className="mt-5 w-full"
                                size="lg"
                                variant="primary"
                                duration={holdDuration}
                                onConfirm={onConfirm}
                                confirmedLabel="Opening wallet…"
                              >
                                {confirmLabel}
                              </HoldToConfirmButton>
                              <p className="mt-2 text-center text-xs text-muted-foreground">This can&apos;t be undone once it&apos;s on-chain.</p>
                            </>
                          ) : (
                            <div className="flex flex-col items-center pt-4 text-center">
                              {status === "awaiting-signature" ? (
                                <Ring tone="muted" />
                              ) : status === "pending" ? (
                                <Ring tone="primary" />
                              ) : (
                                <Outcome ok={status === "confirmed"} reduceMotion={reduceMotion} />
                              )}
                              <Dialog.Title className="mt-4 text-base font-semibold tracking-tight text-foreground">{heading}</Dialog.Title>
                              <p className="mt-1 max-w-64 text-[13px] leading-5 text-muted-foreground">
                                {status === "awaiting-signature"
                                  ? "Approve the request in your wallet to continue."
                                  : status === "pending"
                                    ? "Waiting for the network to confirm it. You can close this window."
                                    : status === "confirmed"
                                      ? "It's final and on-chain."
                                      : error}
                              </p>
                              {status !== "awaiting-signature" && explorer ? <div className="mt-3">{explorer}</div> : null}
                              {status === "confirmed" ? (
                                <div className="mt-5 flex w-full">
                                  <Dialog.Close className={solidButton}>Done</Dialog.Close>
                                </div>
                              ) : status === "failed" ? (
                                <div className="mt-5 flex w-full gap-2">
                                  <Dialog.Close className={ghostButton}>Close</Dialog.Close>
                                  {onRetry ? (
                                    <button type="button" onClick={onRetry} className={solidButton}>
                                      Try again
                                    </button>
                                  ) : null}
                                </div>
                              ) : null}
                            </div>
                          )}
                        </motion.div>
                      </AnimatePresence>
                      <span className="sr-only" role="status" aria-live="polite">
                        {status === "idle" ? "" : `${heading}.`}
                      </span>
                    </motion.div>
                  </Dialog.Content>
                </div>
              </Dialog.Portal>
            ) : null}
          </AnimatePresence>
        </Dialog.Root>
      );
    }

Usage

import { TransactionReviewModal } from "@/components/ui/transaction-review-modal/transaction-review-modal";

<TransactionReviewModal
  trigger={<button>Review swap</button>}
  status={status} // "idle" → "awaiting-signature" → "pending" → "confirmed" | "failed"
  assets={[
    { label: "You pay", token: eth, amount: amountIn },
    { label: "You receive", token: usdc, amount: amountOut },
  ]}
  details={[{ label: "Network fee", value: "0.0018 ETH" }]}
  onConfirm={() => sendTransaction()}
  txHash={hash}
  explorerUrl="https://etherscan.io"
/>

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.
titlestring"Review transaction"Dialog title on the review step.
assetsReviewAsset[]–Assets moving, shown top to bottom with an arrow between them.
detailsReviewDetail[]–Rows such as network fee and total.
warningReactNode–A warning shown above the confirm button, e.g. high price impact.
statusTxStatus"idle"Transaction lifecycle. "idle" is the review step.
onConfirm*() => void–Called when the hold-to-confirm completes. Request the signature here.
confirmLabelstring"Hold to confirm"Label of the hold button.
holdDurationnumber1200Milliseconds to hold the button.
txHashstring–Hash of the submitted transaction, linked from the pending and done states.
explorerUrlstring–Block explorer base URL.
errorstring"The transaction was rejected or reverted."Reason shown in the failed state.
onRetry() => void–Called from "Try again" in the failed state.
successTitlestring"Transaction confirmed"Heading of the confirmed state.
  • The modal never sends anything itself: onConfirm is where you request the signature, and you drive status from your wallet layer.
  • Amounts are bigint values formatted with their token decimals, and USD values are bigint with 8 decimals.

Accessibility

  • Built on Radix Dialog with focus trapping and focus return. The title changes with each step, and a live region announces it.
  • Irreversible actions need a deliberate hold on Hold to Confirm, which also works with the keyboard (hold Space or Enter).
  • Warnings are announced as alerts and carry an icon and text, never color alone.
  • Reduced motion swaps steps with a fade, draws the check instantly and replaces spinners with a soft pulse.