"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
ArrowDownUp,
Check,
ChevronDown,
Loader2,
Search,
Send,
Settings,
Wallet,
X,
} from "lucide-react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
/* ============================================================
* Types + mock data
* ============================================================ */
export type Chain = { id: string; name: string; color: string; symbol: string };
export type Token = {
id: string;
symbol: string;
name: string;
chainId: string;
address?: string;
balance?: number;
usd?: number;
trending?: boolean;
popular?: boolean;
};
const CHAINS: Chain[] = [
{ id: "eth", name: "Ethereum", color: "#627EEA", symbol: "Ξ" },
{ id: "sol", name: "Solana", color: "#9945FF", symbol: "◎" },
{ id: "base", name: "Base", color: "#0052FF", symbol: "B" },
{ id: "arb", name: "Arbitrum", color: "#28A0F0", symbol: "A" },
{ id: "op", name: "Optimism", color: "#FF0420", symbol: "O" },
{ id: "poly", name: "Polygon", color: "#8247E5", symbol: "P" },
{ id: "bnb", name: "BNB", color: "#F0B90B", symbol: "B" },
{ id: "avax", name: "Avalanche", color: "#E84142", symbol: "A" },
];
const TOKENS: Token[] = [
{
id: "eth-eth",
symbol: "ETH",
name: "Ether",
chainId: "eth",
balance: 1.245,
usd: 3142,
popular: true,
},
{
id: "eth-weth",
symbol: "WETH",
name: "Wrapped Ether",
chainId: "eth",
balance: 0.5,
usd: 3142,
popular: true,
},
{
id: "eth-usdc",
symbol: "USDC",
name: "USD Coin",
chainId: "eth",
balance: 4521,
usd: 1,
popular: true,
},
{
id: "eth-usdt",
symbol: "USDT",
name: "Tether",
chainId: "eth",
balance: 2100,
usd: 1,
popular: true,
},
{
id: "eth-dai",
symbol: "DAI",
name: "Dai Stablecoin",
chainId: "eth",
balance: 800,
usd: 1,
popular: true,
},
{
id: "eth-wbtc",
symbol: "WBTC",
name: "Wrapped Bitcoin",
chainId: "eth",
balance: 0.04,
usd: 96000,
popular: true,
},
{
id: "eth-link",
symbol: "LINK",
name: "ChainLink Token",
chainId: "eth",
balance: 120,
usd: 22,
address: "0x51...86ca",
trending: true,
},
{
id: "eth-aztec",
symbol: "AZTEC",
name: "AZTEC",
chainId: "eth",
balance: 0,
address: "0xa2...62d2",
trending: true,
},
{
id: "eth-cfg",
symbol: "CFG",
name: "Centrifuge",
chainId: "eth",
balance: 0,
address: "0xcc...8a94",
trending: true,
},
{
id: "eth-ondo",
symbol: "ONDO",
name: "Ondo",
chainId: "eth",
balance: 0,
address: "0xfa...9be3",
trending: true,
},
{
id: "sol-sol",
symbol: "SOL",
name: "Solana",
chainId: "sol",
balance: 12.4,
usd: 168,
},
{
id: "sol-usdc",
symbol: "USDC",
name: "USD Coin",
chainId: "sol",
balance: 985.32,
usd: 1,
},
{
id: "base-eth",
symbol: "ETH",
name: "Ether",
chainId: "base",
balance: 0.65,
usd: 3142,
},
{
id: "base-usdc",
symbol: "USDC",
name: "USD Coin",
chainId: "base",
balance: 1200,
usd: 1,
},
{
id: "arb-arb",
symbol: "ARB",
name: "Arbitrum",
chainId: "arb",
balance: 800,
usd: 0.95,
},
{
id: "arb-eth",
symbol: "ETH",
name: "Ether",
chainId: "arb",
balance: 0.32,
usd: 3142,
},
{
id: "op-op",
symbol: "OP",
name: "Optimism",
chainId: "op",
balance: 450,
usd: 2.8,
},
{
id: "poly-matic",
symbol: "MATIC",
name: "Polygon",
chainId: "poly",
balance: 1500,
usd: 0.75,
},
{
id: "bnb-bnb",
symbol: "BNB",
name: "BNB",
chainId: "bnb",
balance: 0,
usd: 620,
},
{
id: "avax-avax",
symbol: "AVAX",
name: "Avalanche",
chainId: "avax",
balance: 0,
usd: 38,
},
];
const EASE = [0.16, 1, 0.3, 1] as const;
const DRAWER_EASE = [0.32, 0.72, 0, 1] as const;
/* ============================================================
* MultiChainSwap
* ============================================================ */
export interface MultiChainSwapProps {
chains?: Chain[];
tokens?: Token[];
defaultFromId?: string;
defaultToId?: string;
className?: string;
}
export function MultiChainSwap({
chains = CHAINS,
tokens = TOKENS,
defaultFromId = "eth-eth",
defaultToId = "sol-sol",
className,
}: MultiChainSwapProps) {
const reduce = useReducedMotion();
const [fromId, setFromId] = useState(defaultFromId);
const [toId, setToId] = useState(defaultToId);
const [amount, setAmount] = useState("1");
const [flipRot, setFlipRot] = useState(0);
const [quoting, setQuoting] = useState(false);
const [picking, setPicking] = useState<"from" | "to" | null>(null);
const [showDest, setShowDest] = useState(false);
const [destAddress, setDestAddress] = useState("");
const from = tokens.find((t) => t.id === fromId)!;
const to = tokens.find((t) => t.id === toId)!;
const fromChain = chains.find((c) => c.id === from.chainId)!;
const toChain = chains.find((c) => c.id === to.chainId)!;
const numericAmount = Number(amount) || 0;
const rate = useMemo(() => {
if (!from.usd || !to.usd) return 1;
return from.usd / to.usd;
}, [from.usd, to.usd]);
const toAmount = numericAmount * rate;
const networkFee = 0.42;
const eta = "≈ 24s";
useEffect(() => {
if (numericAmount === 0) return;
setQuoting(true);
const id = setTimeout(() => setQuoting(false), 450);
return () => clearTimeout(id);
}, [numericAmount, fromId, toId]);
const flip = () => {
setFlipRot((r) => r + 180);
setFromId(toId);
setToId(fromId);
};
const pickToken = (id: string) => {
if (!picking) return;
if (picking === "from") {
if (id === toId) setToId(fromId);
setFromId(id);
} else {
if (id === fromId) setFromId(toId);
setToId(id);
}
setPicking(null);
};
return (
{/* Header */}
Swap
{/* Swap form (always mounted, fixed height) */}
setPicking("from")}
/>
0 ? formatAmount(toAmount) : ""}
editable={false}
quoting={quoting}
onOpenPicker={() => setPicking("to")}
/>
{
if (showDest) setDestAddress("");
setShowDest((v) => !v);
}}
address={destAddress}
onAddress={setDestAddress}
reduce={!!reduce}
/>
{/* Token picker — bottom sheet anchored to this card */}
setPicking(null)}
reduce={!!reduce}
/>
);
}
/* ============================================================
* Field
* ============================================================ */
function Field({
side,
token,
chain,
amount,
onAmount,
editable,
quoting,
onOpenPicker,
}: {
side: "from" | "to";
token: Token;
chain: Chain;
amount: string;
onAmount?: (v: string) => void;
editable: boolean;
quoting: boolean;
onOpenPicker: () => void;
}) {
const id = useId();
const usdValue = (Number(amount) || 0) * (token.usd ?? 0);
return (
{editable ? (
onAmount?.(sanitizeAmount(e.target.value))}
placeholder="0"
className="w-full bg-transparent text-2xl font-semibold tracking-tight text-(--color-fg) tabular-nums outline-none placeholder:text-(--color-fg-muted)/60"
/>
) : (
{amount || "0"}
{quoting ? (
) : null}
)}
≈ ${formatAmount(usdValue, 2)}
{token.balance ? formatAmount(token.balance) : "0.00"}
· {chain.name}
{side === "from" && token.balance ? (
) : null}
);
}
/* ============================================================
* Flip button
* ============================================================ */
function FlipButton({
rotation,
reduce,
onClick,
}: {
rotation: number;
reduce: boolean;
onClick: () => void;
}) {
return (
);
}
/* ============================================================
* Quote row
* ============================================================ */
function QuoteRow({
from,
to,
rate,
fee,
slippage,
eta,
quoting,
}: {
from: Token;
to: Token;
rate: number;
fee: number;
slippage: number;
eta: string;
quoting: boolean;
}) {
return (
Rate
{quoting ? (
) : (
<>
1 {from.symbol} ≈ {formatAmount(rate)} {to.symbol}
>
)}
Network fee
${fee.toFixed(2)}
Slippage
{slippage.toFixed(2)}%
ETA
{eta}
);
}
/* ============================================================
* Action button
* ============================================================ */
function ActionButton({
from,
to,
amount,
destAddress,
}: {
from: Token;
to: Token;
amount: number;
destAddress: string;
}) {
const noAmount = amount <= 0;
const overBalance = from.balance !== undefined && amount > from.balance;
const validDest = destAddress && isValidAddress(destAddress);
const label = noAmount
? "Enter an amount"
: overBalance
? `Insufficient ${from.symbol}`
: validDest
? `Swap + Send to ${truncateAddress(destAddress)}`
: `Swap ${from.symbol} → ${to.symbol}`;
const disabled = noAmount || overBalance;
return (
{label}
);
}
/* ============================================================
* Destination address row
* ============================================================ */
function DestinationRow({
show,
onToggle,
address,
onAddress,
reduce,
}: {
show: boolean;
onToggle: () => void;
address: string;
onAddress: (v: string) => void;
reduce: boolean;
}) {
const inputRef = useRef(null);
const hasAddress = address.length > 0;
const valid = isValidAddress(address);
useEffect(() => {
if (!show) return;
requestAnimationFrame(() =>
inputRef.current?.focus({ preventScroll: true }),
);
}, [show]);
return (
{show ? (
) : null}
);
}
/* ============================================================
* TokenPicker — bottom sheet anchored to swap card
* ============================================================ */
function TokenPicker({
open,
side,
chains,
tokens,
selectedId,
onPick,
onClose,
reduce,
}: {
open: boolean;
side: "from" | "to" | null;
chains: Chain[];
tokens: Token[];
selectedId: string;
onPick: (id: string) => void;
onClose: () => void;
reduce: boolean;
}) {
const [chainFilter, setChainFilter] = useState("all"); // "all" or chain.id
const [q, setQ] = useState("");
const inputRef = useRef(null);
useEffect(() => {
if (!open) return;
setQ("");
// Focus without letting the browser scroll the page to bring the input
// into view — that's what causes the swap form behind to "shift up".
requestAnimationFrame(() =>
inputRef.current?.focus({ preventScroll: true }),
);
}, [open]);
// Esc to close
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
const popular = useMemo(
() => tokens.filter((t) => t.popular).slice(0, 6),
[tokens],
);
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase();
return tokens.filter((t) => {
if (chainFilter !== "all" && t.chainId !== chainFilter) return false;
if (!needle) return true;
const chain = chains.find((c) => c.id === t.chainId);
return [t.symbol, t.name, chain?.name, t.address].some((h) =>
h?.toLowerCase().includes(needle),
);
});
}, [q, chainFilter, tokens, chains]);
return (
{open ? (
<>
{/* Backdrop inside the card */}
{/* Sheet panel */}
{/* Drag handle (visual only) */}
{/* Search */}
setQ(e.target.value)}
placeholder="Search name or paste address"
className="w-full bg-transparent text-sm text-(--color-fg) outline-none placeholder:text-(--color-fg-muted)/70"
/>
{/* Chain chips */}
setChainFilter("all")}
label="All"
/>
{chains.map((c) => (
setChainFilter(c.id)}
chain={c}
/>
))}
{/* Scroll area */}
{/* Popular */}
{!q && chainFilter === "all" ? (
<>
Most popular
{popular.map((t) => {
const chain = chains.find((c) => c.id === t.chainId)!;
return (
);
})}
>
) : null}
{/* Trending / All */}
{q ? "Results" : chainFilter === "all" ? "Trending" : "Tokens"}
{filtered.length === 0 ? (
-
No tokens found
) : null}
{filtered.map((t) => {
const chain = chains.find((c) => c.id === t.chainId)!;
const active = t.id === selectedId;
return (
-
);
})}
>
) : null}
);
}
function ChainChip({
active,
onClick,
chain,
label,
}: {
active: boolean;
onClick: () => void;
chain?: Chain;
label?: string;
}) {
return (
);
}
/* ============================================================
* Bits
* ============================================================ */
function ChainDot({ chain, size = 16 }: { chain: Chain; size?: number }) {
return (
{chain.symbol}
);
}
function TokenDot({
token,
chain,
size = 28,
}: {
token: Token;
chain: Chain;
size?: number;
}) {
return (
{token.symbol.slice(0, 2)}
{chain.symbol}
);
}
function isValidAddress(v: string) {
if (/^0x[0-9a-fA-F]{40}$/.test(v)) return true;
if (/\.(eth|sol|bnb)$/.test(v) && v.length > 5) return true;
return false;
}
function truncateAddress(v: string) {
if (v.startsWith("0x") && v.length === 42)
return v.slice(0, 6) + "…" + v.slice(-4);
return v;
}
function sanitizeAmount(v: string) {
const cleaned = v.replace(/[^0-9.]/g, "");
const parts = cleaned.split(".");
if (parts.length <= 1) return cleaned;
return parts[0] + "." + parts.slice(1).join("");
}
function formatAmount(n: number, max = 6) {
if (!isFinite(n)) return "0";
if (n === 0) return "0";
if (n >= 1000)
return n.toLocaleString(undefined, { maximumFractionDigits: 2 });
return n.toLocaleString(undefined, { maximumFractionDigits: max });
}
// Drawer ease retained for callers that import the curve.
export const SWAP_DRAWER_EASE = DRAWER_EASE;