Skip to content

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.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, --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);
    }
  4. 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&apos;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

PropTypeDefaultDescription
tokens*Token[]–Tokens offered in the selectors.
balancesRecord<string, bigint>–Balances by symbol, in each token's smallest unit.
popularstring[]–Symbols popular in the token selector.
defaultFromstringtokens[0]Initial input token symbol.
defaultTostringtokens[1]Initial output token symbol.
defaultAmountstring""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).
quoteLoadingbooleanfalseShimmer the output and hold the button while a quote loads.
quoteTtlnumber20Seconds a quote stays fresh; a ring counts down, then onQuoteRefresh fires.
onQuoteRefresh() => void–Called when the countdown ends. Fetch a new quote here.
approvalApprovalStatus"approved"Token approval state for the input token.
onApprove(token: Token, amount: bigint) => void–Called from the Approve button.
statusSwapStatus"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.
slippageBpsnumber–Max slippage in basis points, shown in the header.
onSlippageChange(bps: number) => void–Called when slippage changes. Shows the slippage settings when provided.
titlestring"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.