Web3: Wallet & Identityv0.2.0
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.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-merge viemAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport 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.tsimport { 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; }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); }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
| Prop | Type | Default | Description |
|---|---|---|---|
toasts* | TransactionToastItem[] | – | Toasts, oldest first. The newest sits in front. |
onDismiss | (id: string) => void | – | Called when a toast is dismissed (button or auto-dismiss). |
explorerUrl | string | – | Block explorer base URL for hash links. |
dismissAfter | number | 0 | Auto-dismiss settled (confirmed or failed) toasts after this many ms. 0 keeps them. |
visibleToasts | number | 3 | Toasts 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.