Web3: DeFi & Tradingv0.2.0
Token Swap Widget
Flip-to-swap panels, a quote countdown, price impact and a staged button.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/token-swap-widget.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, --success-foreground, and --warning, which shadcn does not define by default. Add them to your global CSS:
app/globals.css:root { --success: #15803d; --success-foreground: #ffffff; --warning: #b45309; } .dark { --success: #4ade80; --success-foreground: #052e16; --warning: #fbbf24; } @theme inline { --color-success: var(--success); --color-success-foreground: var(--success-foreground); --color-warning: var(--warning); }Copy the source
components/ui/token-swap-widget/token-swap-widget.tsx"use client"; import { useEffect, useRef, useState, type ComponentProps } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AlertTriangle, ArrowDownUp, Check, ChevronDown, ChevronRight } from "lucide-react"; import { formatUnits } from "viem"; import { SlippageSettings } from "@/components/ui/slippage-settings/slippage-settings"; import { TokenAmountInput } from "@/components/ui/token-amount-input/token-amount-input"; import { TokenSelectorModal } from "@/components/ui/token-selector-modal/token-selector-modal"; import { cn } from "@/lib/utils"; import { duration, ease, spring } from "@/lib/motion"; import { formatAmount, formatBps, parseAmount } from "@/lib/web3/format"; import type { Token } from "@/lib/web3/types"; export interface SwapQuote { /** Output amount in the output token's smallest unit. */ amountOut: bigint; /** Least the swap may return after slippage. */ minimumOut: bigint; /** Output units per one whole input token, in output decimals. */ rate: bigint; /** Price impact in basis points. */ priceImpactBps: bigint; /** Protocol fee in basis points. */ feeBps?: bigint; /** Hops by symbol, e.g. ["ARB", "ETH", "USDC"]. */ route?: string[]; /** Pre-formatted network fee, e.g. "$4.12". */ networkFee?: string; } export type SwapStatus = "idle" | "swapping" | "success" | "failed"; export type ApprovalStatus = "required" | "pending" | "approved"; export interface TokenSwapWidgetProps extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Tokens offered in the selectors. */ tokens: Token[]; /** Balances by symbol, in each token's smallest unit. */ balances?: Record<string, bigint>; /** Symbols popular in the token selector. */ popular?: string[]; /** Initial input token symbol. @default tokens[0] */ defaultFrom?: string; /** Initial output token symbol. @default tokens[1] */ defaultTo?: string; /** Initial amount text for the input token. @default "" */ defaultAmount?: string; /** Returns a quote for an input amount. Called on every change, so keep it fast (or memoize your async result). */ getQuote?: (from: Token, to: Token, amountIn: bigint) => SwapQuote | null; /** Shimmer the output and hold the button while a quote loads. @default false */ quoteLoading?: boolean; /** Seconds a quote stays fresh; a ring counts down, then onQuoteRefresh fires. @default 20 */ quoteTtl?: number; /** Called when the countdown ends. Fetch a new quote here. */ onQuoteRefresh?: () => void; /** Token approval state for the input token. @default "approved" */ approval?: ApprovalStatus; /** Called from the Approve button. */ onApprove?: (token: Token, amount: bigint) => void; /** Swap lifecycle. A return from "success" to "idle" clears the amount. @default "idle" */ status?: SwapStatus; /** Called from the Swap button with everything needed to build the transaction. */ onSwap?: (swap: { from: Token; to: Token; amountIn: bigint; quote: SwapQuote }) => void; /** Max slippage in basis points, shown in the header. */ slippageBps?: number; /** Called when slippage changes. Shows the slippage settings when provided. */ onSlippageChange?: (bps: number) => void; /** Header title. @default "Swap" */ title?: string; } type Slot = "a" | "b"; function Countdown({ ttl, restartKey, onDone }: { ttl: number; restartKey: string; onDone: () => void }) { const ref = useRef<SVGCircleElement>(null); const done = useRef(onDone); useEffect(() => { done.current = onDone; }); useEffect(() => { const circle = ref.current; if (!circle || typeof circle.animate !== "function") return; const animation = circle.animate([{ strokeDashoffset: 0 }, { strokeDashoffset: 1 }], { duration: ttl * 1000, easing: "linear", fill: "forwards" }); animation.onfinish = () => done.current(); return () => animation.cancel(); }, [ttl, restartKey]); return ( <svg viewBox="0 0 20 20" className="size-4 -rotate-90" aria-hidden="true"> <circle cx="10" cy="10" r="7.5" fill="none" stroke="var(--border)" strokeWidth="2.5" /> <circle ref={ref} cx="10" cy="10" r="7.5" fill="none" stroke="var(--primary)" strokeWidth="2.5" strokeLinecap="round" pathLength={1} strokeDasharray="1 1" /> </svg> ); } const Spinner = () => ( <svg className="size-4 animate-spin motion-reduce:animate-pulse" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.3" strokeWidth="2.5" /> <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /> </svg> ); export function TokenSwapWidget({ tokens, balances = {}, popular, defaultFrom, defaultTo, defaultAmount = "", getQuote, quoteLoading = false, quoteTtl = 20, onQuoteRefresh, approval = "approved", onApprove, status = "idle", onSwap, slippageBps, onSlippageChange, title = "Swap", className, ...props }: TokenSwapWidgetProps) { const reduceMotion = useReducedMotion() ?? false; const [sides, setSides] = useState<Record<Slot, string>>({ a: defaultFrom ?? tokens[0]?.symbol ?? "", b: defaultTo ?? tokens[1]?.symbol ?? "", }); const [fromSlot, setFromSlot] = useState<Slot>("a"); const [amount, setAmount] = useState(defaultAmount); const [turns, setTurns] = useState(0); const [selecting, setSelecting] = useState<Slot | null>(null); const [inverted, setInverted] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); const [refreshes, setRefreshes] = useState(0); const toSlot: Slot = fromSlot === "a" ? "b" : "a"; const find = (symbol: string) => tokens.find((t) => t.symbol === symbol); const from = find(sides[fromSlot]); const to = find(sides[toSlot]); const amountIn = from ? parseAmount(amount, from.decimals) : null; const quote = from && to && amountIn && amountIn > BigInt(0) && getQuote ? getQuote(from, to, amountIn) : null; const balance = from ? balances[from.symbol] : undefined; const insufficient = amountIn !== null && balance !== undefined && amountIn > balance; const impact = quote?.priceImpactBps ?? BigInt(0); // Clear the form once a finished swap is acknowledged (status goes back to idle). const [previousStatus, setPreviousStatus] = useState(status); if (status !== previousStatus) { setPreviousStatus(status); if (previousStatus === "success" && status === "idle") setAmount(""); } function flip() { setTurns(turns + 1); setFromSlot(toSlot); // Carry the output over, cut (never rounded up) to 6 decimals so the field stays readable. setAmount(quote && to ? formatUnits(quote.amountOut, to.decimals).replace(/(\.\d{6})\d+$/, "$1") : ""); } function pick(token: Token) { if (!selecting) return; setSides({ ...sides, [selecting]: token.symbol }); } const stage = (() => { if (!from || !to) return { label: "Select tokens", disabled: true }; if (!amountIn || amountIn === BigInt(0)) return { label: "Enter an amount", disabled: true }; if (insufficient) return { label: `Insufficient ${from.symbol} balance`, disabled: true, tone: "error" as const }; if (quoteLoading || !quote) return { label: "Finding the best price…", disabled: true, busy: true }; if (approval === "required") return { label: `Approve ${from.symbol}`, action: () => onApprove?.(from, amountIn) }; if (approval === "pending") return { label: `Approving ${from.symbol}…`, disabled: true, busy: true }; if (status === "swapping") return { label: "Swapping…", disabled: true, busy: true }; if (status === "success") return { label: "Swapped", disabled: true, done: true }; if (status === "failed") return { label: "Swap failed · Try again", action: () => onSwap?.({ from, to, amountIn, quote }), tone: "error" as const }; if (impact >= BigInt(1000)) return { label: "Swap anyway", action: () => onSwap?.({ from, to, amountIn, quote }), tone: "error" as const }; return { label: "Swap", action: () => onSwap?.({ from, to, amountIn, quote }) }; })(); const inverse = quote && from && to && quote.rate > BigInt(0) ? (BigInt(10) ** BigInt(from.decimals + to.decimals)) / quote.rate : undefined; const rateText = quote && from && to ? inverted && inverse !== undefined ? `1 ${to.symbol} = ${formatAmount(inverse, from.decimals, { maxFractionDigits: 6 })} ${from.symbol}` : `1 ${from.symbol} = ${formatAmount(quote.rate, to.decimals, { maxFractionDigits: 4 })} ${to.symbol}` : null; const impactTone = impact >= BigInt(1000) ? "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]" : impact >= BigInt(300) ? "text-[color-mix(in_oklab,var(--warning)_55%,var(--foreground))]" : "text-foreground"; const panel = (slot: Slot) => { const isFrom = slot === fromSlot; const token = find(sides[slot]); return ( <motion.div key={slot} layout={reduceMotion ? false : "position"} transition={spring.smooth}> {isFrom ? ( <TokenAmountInput label="You pay" token={token} balance={token ? balances[token.symbol] : undefined} value={amount} onValueChange={(text) => setAmount(text)} onTokenClick={() => setSelecting(slot)} /> ) : ( <TokenAmountInput label="You receive" token={token} value={quote && token ? formatAmount(quote.amountOut, token.decimals, { maxFractionDigits: 6 }) : ""} readOnly loading={quoteLoading && Boolean(amountIn)} percentages={[]} onTokenClick={() => setSelecting(slot)} /> )} </motion.div> ); }; return ( <div className={cn("w-full max-w-md rounded-3xl border border-border bg-background p-2 shadow-md", className)} {...props}> <div className="flex h-11 items-center justify-between pr-1 pl-3"> <p className="text-sm font-semibold text-foreground">{title}</p> {onSlippageChange && slippageBps !== undefined ? <SlippageSettings value={slippageBps} onValueChange={onSlippageChange} /> : null} </div> <div className="relative space-y-1"> {fromSlot === "a" ? [panel("a"), panel("b")] : [panel("b"), panel("a")]} <div className="absolute inset-x-0 top-1/2 z-10 flex -translate-y-1/2 justify-center"> <motion.button type="button" onClick={flip} aria-label={`Switch: pay with ${to?.symbol ?? "the other token"} instead`} animate={{ rotate: turns * 180 }} transition={reduceMotion ? { duration: 0 } : spring.snappy} whileTap={reduceMotion ? undefined : { scale: 0.9 }} className="grid size-10 cursor-pointer place-items-center rounded-xl border-4 border-background bg-surface text-foreground shadow-sm outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring" > <ArrowDownUp className="size-4" aria-hidden="true" /> </motion.button> </div> </div> <AnimatePresence initial={false}> {quote && rateText ? ( <motion.div key="quote" initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: duration.quick, ease: ease.out }} className="px-2 pt-2" > <div className="flex items-center justify-between gap-2"> <button type="button" onClick={() => setInverted(!inverted)} aria-label={`${rateText}. Show the inverse rate`} className="flex min-w-0 cursor-pointer items-center gap-2 rounded-md px-1 py-1 font-mono text-xs text-muted-foreground tabular-nums outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" > <Countdown ttl={quoteTtl} restartKey={`${sides.a}:${sides.b}:${fromSlot}:${amount}:${refreshes}`} onDone={() => { setRefreshes((n) => n + 1); onQuoteRefresh?.(); }} /> <span className="truncate">{rateText}</span> </button> <button type="button" aria-expanded={detailsOpen} onClick={() => setDetailsOpen(!detailsOpen)} className="flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" > <span className={cn("font-mono tabular-nums", impactTone)}>{formatBps(impact)}</span> impact <ChevronDown className={cn("size-3.5 transition-transform duration-200", detailsOpen && "rotate-180")} aria-hidden="true" /> </button> </div> <div className={cn("grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none", detailsOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]")} inert={!detailsOpen}> <dl className="min-h-0 space-y-1.5 overflow-hidden px-1 text-xs"> <div className="flex justify-between gap-3 pt-2"> <dt className="text-muted-foreground">Price impact</dt> <dd className={cn("font-mono tabular-nums", impactTone)}>{formatBps(impact)}</dd> </div> <div className="flex justify-between gap-3"> <dt className="text-muted-foreground">Minimum received</dt> <dd className="font-mono text-foreground tabular-nums"> {to ? `${formatAmount(quote.minimumOut, to.decimals)} ${to.symbol}` : "—"} </dd> </div> {quote.feeBps !== undefined ? ( <div className="flex justify-between gap-3"> <dt className="text-muted-foreground">Fee</dt> <dd className="font-mono text-foreground tabular-nums">{formatBps(quote.feeBps)}</dd> </div> ) : null} {quote.networkFee ? ( <div className="flex justify-between gap-3"> <dt className="text-muted-foreground">Network fee</dt> <dd className="font-mono text-foreground tabular-nums">{quote.networkFee}</dd> </div> ) : null} {quote.route?.length ? ( <div className="flex justify-between gap-3 pb-1"> <dt className="text-muted-foreground">Route</dt> <dd className="flex items-center gap-1 text-foreground"> {quote.route.map((hop, index) => ( <span key={`${hop}-${index}`} className="flex items-center gap-1"> {index > 0 ? <ChevronRight className="size-3 text-muted-foreground" aria-label="then" /> : null} <span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">{hop}</span> </span> ))} </dd> </div> ) : null} </dl> </div> </motion.div> ) : null} </AnimatePresence> {impact >= BigInt(1000) && status === "idle" ? ( <div role="alert" className="mx-1 mt-2 flex gap-2 rounded-xl bg-destructive/8 p-3 text-xs leading-5 text-foreground"> <AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]" aria-hidden="true" /> Price impact is {formatBps(impact)}. You'd receive much less than the market rate; try a smaller amount. </div> ) : null} <button type="button" onClick={stage.action} disabled={stage.disabled} aria-busy={stage.busy || undefined} className={cn( "mt-2 flex h-13 w-full cursor-pointer items-center justify-center overflow-hidden rounded-2xl text-[15px] font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed", stage.tone === "error" ? "bg-destructive/10 text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))] enabled:hover:bg-destructive/15" : stage.done ? "bg-success text-success-foreground" : stage.disabled && !stage.busy ? "bg-muted text-muted-foreground" : "bg-foreground text-background enabled:hover:bg-foreground/90", )} > <AnimatePresence mode="popLayout" initial={false}> <motion.span key={stage.label} className="flex items-center gap-2" initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8, filter: "blur(4px)" }} transition={{ duration: duration.quick, ease: ease.out }} > {stage.busy ? <Spinner /> : null} {stage.done ? <Check className="size-4" strokeWidth={3} aria-hidden="true" /> : null} {stage.label} </motion.span> </AnimatePresence> </button> <span className="sr-only" role="status" aria-live="polite"> {stage.busy || stage.done || stage.tone === "error" ? stage.label : ""} </span> <TokenSelectorModal open={selecting !== null} onOpenChange={(open) => { if (!open) setSelecting(null); }} tokens={tokens} balances={balances} popular={popular} selected={selecting ? sides[selecting] : undefined} disabledSymbol={selecting ? sides[selecting === "a" ? "b" : "a"] : undefined} onSelect={pick} /> </div> ); }
Usage
import { TokenSwapWidget } from "@/components/ui/token-swap-widget/token-swap-widget";
<TokenSwapWidget
tokens={tokens}
balances={balances}
getQuote={(from, to, amountIn) => quotes.get(from, to, amountIn)} // { amountOut, minimumOut, rate, priceImpactBps, … }
quoteLoading={isFetching}
onQuoteRefresh={refetch}
approval={allowanceOk ? "approved" : approving ? "pending" : "required"}
onApprove={(token, amount) => approve(token, amount)}
status={status} // "idle" | "swapping" | "success" | "failed"
onSwap={({ from, to, amountIn, quote }) => swap(from, to, amountIn, quote.minimumOut)}
slippageBps={slippage}
onSlippageChange={setSlippage}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
tokens* | Token[] | – | Tokens offered in the selectors. |
balances | Record<string, bigint> | – | Balances by symbol, in each token's smallest unit. |
popular | string[] | – | Symbols popular in the token selector. |
defaultFrom | string | tokens[0] | Initial input token symbol. |
defaultTo | string | tokens[1] | Initial output token symbol. |
defaultAmount | string | "" | Initial amount text for the input token. |
getQuote | (from: Token, to: Token, amountIn: bigint) => SwapQuote | null | – | Returns a quote for an input amount. Called on every change, so keep it fast (or memoize your async result). |
quoteLoading | boolean | false | Shimmer the output and hold the button while a quote loads. |
quoteTtl | number | 20 | Seconds a quote stays fresh; a ring counts down, then onQuoteRefresh fires. |
onQuoteRefresh | () => void | – | Called when the countdown ends. Fetch a new quote here. |
approval | ApprovalStatus | "approved" | Token approval state for the input token. |
onApprove | (token: Token, amount: bigint) => void | – | Called from the Approve button. |
status | SwapStatus | "idle" | Swap lifecycle. A return from "success" to "idle" clears the amount. |
onSwap | (swap: { from: Token; to: Token; amountIn: bigint; quote: SwapQuote }) => void | – | Called from the Swap button with everything needed to build the transaction. |
slippageBps | number | – | Max slippage in basis points, shown in the header. |
onSlippageChange | (bps: number) => void | – | Called when slippage changes. Shows the slippage settings when provided. |
title | string | "Swap" | Header title. |
Also accepts every native <div> attribute, including ref and className.
- Reuses Token Amount Input, Token Selector Modal and Slippage Settings, so they install with it.
- Everything is bigint: amounts, rate, minimum received and the inverse rate. The quote countdown restarts whenever tokens or the amount change.
- The widget never signs anything. Drive approval and status from your wallet layer; returning status from "success" to "idle" clears the amount.
Accessibility
- Both sides are labelled Token Amount Inputs; the receive side is read-only and aria-busy while a quote loads.
- The flip button is named after what it does ("Switch: pay with USDC instead") and rotates while the panels trade places.
- The main button names its exact stage (Enter an amount, Insufficient balance, Approve, Swap…), and busy, done and error stages are announced through a live region.
- The rate line toggles the inverse rate; details open with the grid-rows trick. High price impact shows an alert with text and an icon.
- Reduced motion turns the flip, label swaps and panel move into instant changes or fades.