Skip to content

Transaction Toast

A pending ring that resolves into a drawn check or a failure shake.

Installation

CLI

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

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion 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/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/types.ts
    /** Shapes the web3 components accept. Your own token and chain objects only need these fields. */
    
    export interface Token {
      symbol: string;
      name?: string;
      /** Token decimals: 18 for ETH, 6 for USDC. Amounts are bigint in these units. */
      decimals: number;
      address?: string;
      /** Logo URL. Without one, a generated avatar (colored circle and symbol) is shown. */
      iconUrl?: string;
      /** Avatar color for the generated icon. Picked from the symbol when omitted. */
      color?: string;
      /** USD price as a bigint with USD_DECIMALS (8) decimals. */
      price?: bigint;
    }
    
    export interface Chain {
      id: number;
      name: string;
      shortName?: string;
      iconUrl?: string;
      color?: string;
      /** Block explorer base URL without a trailing slash, e.g. https://etherscan.io */
      explorerUrl?: string;
      nativeSymbol?: string;
    }
    
    export interface WalletOption {
      id: string;
      name: string;
      /** Logo URL. Without one, a generated squircle with the wallet's initial is shown. */
      iconUrl?: string;
      color?: string;
      /** Shows a "Recent" badge. */
      recent?: boolean;
      /** The wallet is detected in this browser. */
      installed?: boolean;
      /** Connects by scanning a QR code with a phone instead of a browser extension. */
      qr?: boolean;
      /** Short helper line under the name. */
      description?: string;
    }
    
    /** Lifecycle of anything the user signs: every state gets its own visual. */
    export type TxStatus = "idle" | "awaiting-signature" | "pending" | "confirmed" | "failed";
    
    /** Explorer URL for an address or transaction, or undefined without an explorer. */
    export function explorerLink(explorerUrl: string | undefined, kind: "address" | "tx", value: string) {
      return explorerUrl ? `${explorerUrl.replace(/\/$/, "")}/${kind}/${value}` : undefined;
    }
  3. Add the theme tokens

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

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

    components/ui/transaction-toast/transaction-toast.tsx
    "use client";
    
    import { useEffect, useState, type ComponentProps } from "react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { ArrowUpRight, X } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { spring } from "@/lib/motion";
    import { truncateAddress } from "@/lib/web3/format";
    import { explorerLink } from "@/lib/web3/types";
    
    export type TransactionToastStatus = "pending" | "confirmed" | "failed";
    
    export interface TransactionToastItem {
      id: string;
      /** e.g. "Swapping 0.5 ETH for USDC". */
      title: string;
      /** Second line, e.g. "Confirmed in 12s" or an error reason. */
      description?: string;
      status: TransactionToastStatus;
      /** Transaction hash, linked to the explorer. */
      hash?: string;
      /** Pending progress from 0 to 1. Omit for an indeterminate spinner. */
      progress?: number;
    }
    
    export interface TransactionToastStackProps
      extends Omit<ComponentProps<"section">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Toasts, oldest first. The newest sits in front. */
      toasts: TransactionToastItem[];
      /** Called when a toast is dismissed (button or auto-dismiss). */
      onDismiss?: (id: string) => void;
      /** Block explorer base URL for hash links. */
      explorerUrl?: string;
      /** Auto-dismiss settled (confirmed or failed) toasts after this many ms. 0 keeps them. @default 0 */
      dismissAfter?: number;
      /** Toasts shown in the collapsed stack. @default 3 */
      visibleToasts?: number;
    }
    
    const TOAST_HEIGHT = 68;
    const GAP = 8;
    
    function StatusIcon({ status, progress, reduceMotion }: { status: TransactionToastStatus; progress?: number; reduceMotion: boolean }) {
      return (
        <span className="relative grid size-8 shrink-0 place-items-center *:[grid-area:1/1]" aria-hidden="true">
          <AnimatePresence initial={false}>
            {status === "pending" ? (
              <motion.svg key="pending" viewBox="0 0 32 32" className="size-8" exit={{ opacity: 0, scale: 0.8 }} transition={{ duration: 0.18 }}>
                <circle cx="16" cy="16" r="13" fill="none" stroke="var(--border)" strokeWidth="3" />
                {progress === undefined ? (
                  <g className="origin-center animate-spin [transform-box:fill-box] motion-reduce:animate-pulse">
                    <circle cx="16" cy="16" r="13" fill="none" stroke="var(--primary)" strokeWidth="3" strokeLinecap="round" pathLength={1} strokeDasharray="0.3 1" />
                  </g>
                ) : (
                  <circle
                    cx="16"
                    cy="16"
                    r="13"
                    fill="none"
                    stroke="var(--primary)"
                    strokeWidth="3"
                    strokeLinecap="round"
                    pathLength={1}
                    strokeDasharray="1 1"
                    strokeDashoffset={1 - Math.min(1, Math.max(0, progress))}
                    transform="rotate(-90 16 16)"
                    className="transition-[stroke-dashoffset] duration-500 ease-out"
                  />
                )}
              </motion.svg>
            ) : status === "confirmed" ? (
              <motion.svg
                key="confirmed"
                viewBox="0 0 32 32"
                className="size-8"
                initial={reduceMotion ? { opacity: 0 } : { scale: 0.6, opacity: 0 }}
                animate={{ scale: 1, opacity: 1 }}
                transition={reduceMotion ? { duration: 0.15 } : spring.bouncy}
              >
                <circle cx="16" cy="16" r="14" fill="var(--success)" />
                <motion.path
                  d="M10 16.5l4 4 8-9"
                  fill="none"
                  stroke="#fff"
                  strokeWidth="3"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  initial={{ pathLength: reduceMotion ? 1 : 0 }}
                  animate={{ pathLength: 1 }}
                  transition={{ duration: 0.35, delay: 0.12, ease: "easeOut" }}
                />
              </motion.svg>
            ) : (
              <motion.svg
                key="failed"
                viewBox="0 0 32 32"
                className="size-8"
                initial={reduceMotion ? { opacity: 0 } : { scale: 0.6, opacity: 0 }}
                animate={{ scale: 1, opacity: 1 }}
                transition={reduceMotion ? { duration: 0.15 } : spring.bouncy}
              >
                <circle cx="16" cy="16" r="14" fill="var(--destructive)" />
                <path d="M11.5 11.5l9 9M20.5 11.5l-9 9" stroke="#fff" strokeWidth="3" strokeLinecap="round" />
              </motion.svg>
            )}
          </AnimatePresence>
        </span>
      );
    }
    
    function Toast({
      toast,
      explorerUrl,
      onDismiss,
      dismissAfter,
      reduceMotion,
    }: {
      toast: TransactionToastItem;
      explorerUrl?: string;
      onDismiss?: (id: string) => void;
      dismissAfter: number;
      reduceMotion: boolean;
    }) {
      const href = toast.hash ? explorerLink(explorerUrl, "tx", toast.hash) : undefined;
      const settled = toast.status !== "pending";
    
      useEffect(() => {
        if (!settled || !dismissAfter || !onDismiss) return;
        const timer = window.setTimeout(() => onDismiss(toast.id), dismissAfter);
        return () => window.clearTimeout(timer);
      }, [settled, dismissAfter, onDismiss, toast.id]);
    
      return (
        <motion.div
          animate={toast.status === "failed" && !reduceMotion ? { x: [0, -7, 7, -4, 4, 0] } : { x: 0 }}
          transition={{ duration: 0.42, ease: "easeInOut" }}
          className="flex h-[68px] items-center gap-3 rounded-2xl border border-border bg-popover py-3 pr-2 pl-3 text-popover-foreground shadow-lg"
        >
          <StatusIcon status={toast.status} progress={toast.progress} reduceMotion={reduceMotion} />
          <div className="min-w-0 flex-1">
            <p className="truncate text-sm font-medium text-foreground">{toast.title}</p>
            <p
              className={cn(
                "flex items-center gap-1.5 truncate text-xs",
                toast.status === "failed" ? "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]" : "text-muted-foreground",
              )}
            >
              <span className="truncate">
                {toast.description ?? (toast.status === "pending" ? "Waiting for confirmation…" : toast.status === "confirmed" ? "Confirmed" : "Transaction failed")}
              </span>
              {href ? (
                <a
                  href={href}
                  target="_blank"
                  rel="noreferrer"
                  className="inline-flex shrink-0 items-center gap-0.5 rounded font-mono text-muted-foreground underline decoration-border underline-offset-2 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                >
                  {truncateAddress(toast.hash ?? "", { start: 6, end: 4 })}
                  <ArrowUpRight className="size-3" aria-hidden="true" />
                  <span className="sr-only"> (view transaction)</span>
                </a>
              ) : null}
            </p>
          </div>
          {onDismiss ? (
            <button
              type="button"
              onClick={() => onDismiss(toast.id)}
              aria-label="Dismiss"
              className="relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors after:absolute after:-inset-1 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
            >
              <X className="size-4" aria-hidden="true" />
            </button>
          ) : null}
          <span className="sr-only" role={toast.status === "failed" ? "alert" : "status"}>
            {toast.title}: {toast.status === "pending" ? "pending" : toast.status}
          </span>
        </motion.div>
      );
    }
    
    export function TransactionToastStack({
      toasts,
      onDismiss,
      explorerUrl,
      dismissAfter = 0,
      visibleToasts = 3,
      className,
      ...props
    }: TransactionToastStackProps) {
      const reduceMotion = useReducedMotion() ?? false;
      const [expanded, setExpanded] = useState(false);
      const ordered = [...toasts].reverse();
      const count = ordered.length;
      const height = expanded ? count * TOAST_HEIGHT + Math.max(0, count - 1) * GAP : count ? TOAST_HEIGHT + Math.min(count - 1, visibleToasts - 1) * 10 : 0;
    
      return (
        <section
          aria-label="Transaction notifications"
          onPointerEnter={() => setExpanded(true)}
          onPointerLeave={() => setExpanded(false)}
          onFocus={() => setExpanded(true)}
          onBlur={(event) => {
            if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setExpanded(false);
          }}
          className={cn("relative w-full max-w-sm transition-[height] duration-300 ease-out motion-reduce:transition-none", className)}
          style={{ height }}
          {...props}
        >
          <ol className="contents">
            <AnimatePresence initial={false}>
              {ordered.map((toast, index) => {
                const hidden = !expanded && index >= visibleToasts;
                return (
                  <motion.li
                    key={toast.id}
                    className="absolute inset-x-0 bottom-0"
                    style={{ zIndex: count - index, transformOrigin: "bottom center" }}
                    initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.96 }}
                    animate={{
                      opacity: hidden ? 0 : 1,
                      y: expanded ? -index * (TOAST_HEIGHT + GAP) : -index * 10,
                      scale: expanded ? 1 : 1 - index * 0.05,
                    }}
                    exit={reduceMotion ? { opacity: 0 } : { opacity: 0, x: 40, transition: { duration: 0.2 } }}
                    transition={reduceMotion ? { duration: 0.15 } : spring.smooth}
                    aria-hidden={hidden || undefined}
                    inert={hidden}
                  >
                    <Toast toast={toast} explorerUrl={explorerUrl} onDismiss={onDismiss} dismissAfter={dismissAfter} reduceMotion={reduceMotion} />
                  </motion.li>
                );
              })}
            </AnimatePresence>
          </ol>
        </section>
      );
    }

Usage

import { TransactionToastStack } from "@/components/ui/transaction-toast/transaction-toast";

<TransactionToastStack
  className="fixed right-4 bottom-4 z-50"
  toasts={[{ id: hash, title: "Swapping 0.5 ETH for USDC", status: "pending", hash }]}
  explorerUrl="https://etherscan.io"
  dismissAfter={6000}
  onDismiss={(id) => remove(id)}
/>

API reference

PropTypeDefaultDescription
toasts*TransactionToastItem[]–Toasts, oldest first. The newest sits in front.
onDismiss(id: string) => void–Called when a toast is dismissed (button or auto-dismiss).
explorerUrlstring–Block explorer base URL for hash links.
dismissAfternumber0Auto-dismiss settled (confirmed or failed) toasts after this many ms. 0 keeps them.
visibleToastsnumber3Toasts shown in the collapsed stack.

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

  • The component holds no queue of its own: add, update and remove toasts in your state, keyed by transaction hash.
  • Pass progress (0–1) for a determinate ring, for example confirmations received out of those required.

Accessibility

  • The stack is a labelled region. Each toast has a status message for screen readers, and failures use role="alert".
  • Hovering or focusing the stack fans it open, so every toast and its links are reachable by keyboard; toasts hidden behind the stack are inert.
  • Dismiss buttons are labelled and keep a 40px hit area, and hash links read "view transaction".
  • Reduced motion fades toasts instead of springing them, draws the check instantly and skips the failure shake.