{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "author": "DesignX ", "name": "swap", "type": "registry:component", "title": "Swap", "description": "A token swap widget with quotes and a token picker.", "dependencies": [ "lucide-react@^0.577.0", "motion@^14.0.0" ], "registryDependencies": [ "@dx/lib-ease", "@dx/utils" ], "files": [ { "path": "components/motion/swap.tsx", "type": "registry:component", "target": "components/motion/swap.tsx", "content": "import { useEffect, useMemo, useState } from \"react\";\nimport { Settings } from \"lucide-react\";\nimport { useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\nimport { CHAINS, TOKENS } from \"./swap/data\";\nimport { ActionButton, DestinationRow, FlipButton } from \"./swap/controls\";\nimport { Field } from \"./swap/field\";\nimport { QuoteRow } from \"./swap/quote-row\";\nimport { TokenPicker } from \"./swap/token-picker\";\nimport type { Chain, Token, TokenSide } from \"./swap/types\";\nimport { formatAmount } from \"./swap/utils\";\n\nexport type { Chain, Token } from \"./swap/types\";\nexport { SWAP_DRAWER_EASE } from \"./swap/constants\";\n\nexport interface MultiChainSwapProps {\n chains?: Chain[];\n tokens?: Token[];\n defaultFromId?: string;\n defaultToId?: string;\n className?: string;\n}\n\nexport function MultiChainSwap({\n chains = CHAINS,\n tokens = TOKENS,\n defaultFromId = \"eth-eth\",\n defaultToId = \"sol-sol\",\n className,\n}: MultiChainSwapProps) {\n const reduce = useReducedMotion();\n const [fromId, setFromId] = useState(defaultFromId);\n const [toId, setToId] = useState(defaultToId);\n const [amount, setAmount] = useState(\"1\");\n const [flipRot, setFlipRot] = useState(0);\n const [quoting, setQuoting] = useState(false);\n const [picking, setPicking] = useState(null);\n const [showDest, setShowDest] = useState(false);\n const [destAddress, setDestAddress] = useState(\"\");\n\n const from = findToken(tokens, fromId);\n const to = findToken(tokens, toId);\n const fromChain = findChain(chains, from.chainId);\n const toChain = findChain(chains, to.chainId);\n\n const numericAmount = Number(amount) || 0;\n const quoteKey = `${numericAmount}:${fromId}:${toId}`;\n const rate = useMemo(() => {\n if (!from.usd || !to.usd) return 1;\n return from.usd / to.usd;\n }, [from.usd, to.usd]);\n const toAmount = numericAmount * rate;\n\n useEffect(() => {\n if (!quoteKey) return;\n if (numericAmount === 0) return;\n setQuoting(true);\n const id = setTimeout(() => setQuoting(false), 450);\n return () => clearTimeout(id);\n }, [numericAmount, quoteKey]);\n\n const flip = () => {\n setFlipRot((r) => r + 180);\n setFromId(toId);\n setToId(fromId);\n };\n\n const pickToken = (id: string) => {\n if (!picking) return;\n\n if (picking === \"from\") {\n if (id === toId) setToId(fromId);\n setFromId(id);\n } else {\n if (id === fromId) setFromId(toId);\n setToId(id);\n }\n\n setPicking(null);\n };\n\n return (\n \n
\n \n Swap\n \n \n \n \n
\n\n
\n setPicking(\"from\")}\n />\n\n \n\n 0 ? formatAmount(toAmount) : \"\"}\n editable={false}\n quoting={quoting}\n onOpenPicker={() => setPicking(\"to\")}\n />\n\n \n\n {\n if (showDest) setDestAddress(\"\");\n setShowDest((v) => !v);\n }}\n address={destAddress}\n onAddress={setDestAddress}\n reduce={!!reduce}\n />\n\n \n
\n\n setPicking(null)}\n reduce={!!reduce}\n />\n \n );\n}\n\nfunction findToken(tokens: Token[], id: string) {\n const token = tokens.find((t) => t.id === id);\n if (!token) throw new Error(`Unknown token id: ${id}`);\n return token;\n}\n\nfunction findChain(chains: Chain[], id: string) {\n const chain = chains.find((c) => c.id === id);\n if (!chain) throw new Error(`Unknown chain id: ${id}`);\n return chain;\n}\n" }, { "path": "components/motion/swap/data.ts", "type": "registry:component", "target": "components/motion/swap/data.ts", "content": "import type { Chain, Token } from \"./types\";\n\nexport const CHAINS: Chain[] = [\n { id: \"eth\", name: \"Ethereum\", tone: \"bg-primary text-primary-foreground\", symbol: \"Ξ\" },\n { id: \"sol\", name: \"Solana\", tone: \"bg-secondary text-secondary-foreground\", symbol: \"◎\" },\n { id: \"base\", name: \"Base\", tone: \"bg-accent text-accent-foreground\", symbol: \"B\" },\n { id: \"arb\", name: \"Arbitrum\", tone: \"bg-muted text-muted-foreground\", symbol: \"A\" },\n { id: \"op\", name: \"Optimism\", tone: \"bg-destructive text-primary-foreground\", symbol: \"O\" },\n { id: \"poly\", name: \"Polygon\", tone: \"bg-primary/80 text-primary-foreground\", symbol: \"P\" },\n { id: \"bnb\", name: \"BNB\", tone: \"bg-secondary text-secondary-foreground\", symbol: \"B\" },\n { id: \"avax\", name: \"Avalanche\", tone: \"bg-destructive/80 text-primary-foreground\", symbol: \"A\" },\n];\n\nexport const TOKENS: Token[] = [\n {\n id: \"eth-eth\",\n symbol: \"ETH\",\n name: \"Ether\",\n chainId: \"eth\",\n balance: 1.245,\n usd: 3142,\n popular: true,\n },\n {\n id: \"eth-weth\",\n symbol: \"WETH\",\n name: \"Wrapped Ether\",\n chainId: \"eth\",\n balance: 0.5,\n usd: 3142,\n popular: true,\n },\n {\n id: \"eth-usdc\",\n symbol: \"USDC\",\n name: \"USD Coin\",\n chainId: \"eth\",\n balance: 4521,\n usd: 1,\n popular: true,\n },\n {\n id: \"eth-usdt\",\n symbol: \"USDT\",\n name: \"Tether\",\n chainId: \"eth\",\n balance: 2100,\n usd: 1,\n popular: true,\n },\n {\n id: \"eth-dai\",\n symbol: \"DAI\",\n name: \"Dai Stablecoin\",\n chainId: \"eth\",\n balance: 800,\n usd: 1,\n popular: true,\n },\n {\n id: \"eth-wbtc\",\n symbol: \"WBTC\",\n name: \"Wrapped Bitcoin\",\n chainId: \"eth\",\n balance: 0.04,\n usd: 96000,\n popular: true,\n },\n {\n id: \"eth-link\",\n symbol: \"LINK\",\n name: \"ChainLink Token\",\n chainId: \"eth\",\n balance: 120,\n usd: 22,\n address: \"0x51...86ca\",\n trending: true,\n },\n {\n id: \"eth-aztec\",\n symbol: \"AZTEC\",\n name: \"AZTEC\",\n chainId: \"eth\",\n balance: 0,\n address: \"0xa2...62d2\",\n trending: true,\n },\n {\n id: \"eth-cfg\",\n symbol: \"CFG\",\n name: \"Centrifuge\",\n chainId: \"eth\",\n balance: 0,\n address: \"0xcc...8a94\",\n trending: true,\n },\n {\n id: \"eth-ondo\",\n symbol: \"ONDO\",\n name: \"Ondo\",\n chainId: \"eth\",\n balance: 0,\n address: \"0xfa...9be3\",\n trending: true,\n },\n {\n id: \"sol-sol\",\n symbol: \"SOL\",\n name: \"Solana\",\n chainId: \"sol\",\n balance: 12.4,\n usd: 168,\n },\n {\n id: \"sol-usdc\",\n symbol: \"USDC\",\n name: \"USD Coin\",\n chainId: \"sol\",\n balance: 985.32,\n usd: 1,\n },\n {\n id: \"base-eth\",\n symbol: \"ETH\",\n name: \"Ether\",\n chainId: \"base\",\n balance: 0.65,\n usd: 3142,\n },\n {\n id: \"base-usdc\",\n symbol: \"USDC\",\n name: \"USD Coin\",\n chainId: \"base\",\n balance: 1200,\n usd: 1,\n },\n {\n id: \"arb-arb\",\n symbol: \"ARB\",\n name: \"Arbitrum\",\n chainId: \"arb\",\n balance: 800,\n usd: 0.95,\n },\n {\n id: \"arb-eth\",\n symbol: \"ETH\",\n name: \"Ether\",\n chainId: \"arb\",\n balance: 0.32,\n usd: 3142,\n },\n {\n id: \"op-op\",\n symbol: \"OP\",\n name: \"Optimism\",\n chainId: \"op\",\n balance: 450,\n usd: 2.8,\n },\n {\n id: \"poly-matic\",\n symbol: \"MATIC\",\n name: \"Polygon\",\n chainId: \"poly\",\n balance: 1500,\n usd: 0.75,\n },\n {\n id: \"bnb-bnb\",\n symbol: \"BNB\",\n name: \"BNB\",\n chainId: \"bnb\",\n balance: 0,\n usd: 620,\n },\n {\n id: \"avax-avax\",\n symbol: \"AVAX\",\n name: \"Avalanche\",\n chainId: \"avax\",\n balance: 0,\n usd: 38,\n },\n];\n" }, { "path": "components/motion/swap/controls.tsx", "type": "registry:component", "target": "components/motion/swap/controls.tsx", "content": "import { useEffect, useRef } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ArrowDownUp, Check, ChevronDown, Send, X } from \"lucide-react\";\nimport { SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport type { Token } from \"./types\";\nimport { EASE } from \"./constants\";\nimport { isValidAddress, truncateAddress } from \"./utils\";\n\nexport function FlipButton({\n rotation,\n reduce,\n onClick,\n}: {\n rotation: number;\n reduce: boolean;\n onClick: () => void;\n}) {\n return (\n
\n \n \n \n
\n );\n}\n\nexport function ActionButton({\n from,\n to,\n amount,\n destAddress,\n}: {\n from: Token;\n to: Token;\n amount: number;\n destAddress: string;\n}) {\n const reduce = useReducedMotion();\n const noAmount = amount <= 0;\n const overBalance = from.balance !== undefined && amount > from.balance;\n const validDest = destAddress && isValidAddress(destAddress);\n const label = noAmount\n ? \"Enter an amount\"\n : overBalance\n ? `Insufficient ${from.symbol}`\n : validDest\n ? `Swap + Send to ${truncateAddress(destAddress)}`\n : `Swap ${from.symbol} → ${to.symbol}`;\n const disabled = noAmount || overBalance;\n\n return (\n \n \n \n {label}\n \n \n \n );\n}\n\nexport function DestinationRow({\n show,\n onToggle,\n address,\n onAddress,\n reduce,\n}: {\n show: boolean;\n onToggle: () => void;\n address: string;\n onAddress: (v: string) => void;\n reduce: boolean;\n}) {\n const inputRef = useRef(null);\n const hasAddress = address.length > 0;\n const valid = isValidAddress(address);\n\n useEffect(() => {\n if (!show) return;\n requestAnimationFrame(() =>\n inputRef.current?.focus({ preventScroll: true }),\n );\n }, [show]);\n\n return (\n
\n \n \n \n \n {show && hasAddress && valid\n ? `To: ${truncateAddress(address)}`\n : \"Send to different address\"}\n \n \n \n \n \n \n\n \n {show ? (\n \n
\n \n {/* oxlint-disable-next-line jsx-a11y/control-has-associated-label */}\n onAddress(e.target.value.trim())}\n placeholder=\"0x... or name.eth\"\n spellCheck={false}\n className=\"min-w-0 flex-1 bg-transparent font-mono text-[12px] text-foreground outline-none placeholder:text-muted-foreground/60\"\n />\n \n {hasAddress ? (\n valid ? (\n \n \n \n ) : (\n onAddress(\"\")}\n aria-label=\"Clear address\"\n initial={{ opacity: 0, scale: 0.7 }}\n animate={{ opacity: 1, scale: 1 }}\n exit={{ opacity: 0, scale: 0.7 }}\n transition={{ duration: 0.14, ease: EASE }}\n className=\"shrink-0 text-muted-foreground hover:text-foreground\"\n >\n \n \n )\n ) : null}\n \n
\n
\n \n ) : null}\n \n \n );\n}\n" }, { "path": "components/motion/swap/field.tsx", "type": "registry:component", "target": "components/motion/swap/field.tsx", "content": "import { useId } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { ChevronDown, Loader2, Wallet } from \"lucide-react\";\nimport type { Chain, Token, TokenSide } from \"./types\";\nimport { TokenDot } from \"./token-badges\";\nimport { EASE } from \"./constants\";\nimport { formatAmount, sanitizeAmount } from \"./utils\";\n\nexport function Field({\n side,\n token,\n chain,\n amount,\n onAmount,\n editable,\n quoting,\n onOpenPicker,\n}: {\n side: TokenSide;\n token: Token;\n chain: Chain;\n amount: string;\n onAmount?: (v: string) => void;\n editable: boolean;\n quoting: boolean;\n onOpenPicker: () => void;\n}) {\n const id = useId();\n const usdValue = (Number(amount) || 0) * (token.usd ?? 0);\n\n return (\n
\n \n {side === \"from\" ? \"You pay\" : \"You get\"}\n \n\n
\n
\n {editable ? (\n // oxlint-disable-next-line jsx-a11y/control-has-associated-label\n onAmount?.(sanitizeAmount(e.target.value))}\n placeholder=\"0\"\n className=\"w-full bg-transparent text-2xl font-semibold tracking-tight text-foreground tabular-nums outline-none placeholder:text-muted-foreground/60\"\n />\n ) : (\n
\n \n {amount || \"0\"}\n \n \n {quoting ? (\n \n \n \n ) : null}\n \n
\n )}\n

\n ≈ ${formatAmount(usdValue, 2)}\n

\n
\n\n \n \n {token.symbol}\n \n \n
\n\n
\n \n \n \n {token.balance ? formatAmount(token.balance) : \"0.00\"}\n \n · {chain.name}\n \n {side === \"from\" && token.balance ? (\n onAmount?.(String(token.balance))}\n className=\"rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:bg-muted/60 hover:text-foreground\"\n >\n Max\n \n ) : null}\n
\n
\n );\n}\n" }, { "path": "components/motion/swap/quote-row.tsx", "type": "registry:component", "target": "components/motion/swap/quote-row.tsx", "content": "import { Loader2 } from \"lucide-react\";\nimport type { Token } from \"./types\";\nimport { formatAmount } from \"./utils\";\n\nexport function QuoteRow({\n from,\n to,\n rate,\n fee,\n slippage,\n eta,\n quoting,\n}: {\n from: Token;\n to: Token;\n rate: number;\n fee: number;\n slippage: number;\n eta: string;\n quoting: boolean;\n}) {\n return (\n
\n Rate\n \n {quoting ? (\n \n ) : (\n <>\n 1 {from.symbol} ≈ {formatAmount(rate)} {to.symbol}\n \n )}\n \n Network fee\n \n ${fee.toFixed(2)}\n \n Slippage\n \n {slippage.toFixed(2)}%\n \n ETA\n {eta}\n
\n );\n}\n" }, { "path": "components/motion/swap/token-picker.tsx", "type": "registry:component", "target": "components/motion/swap/token-picker.tsx", "content": "import { useEffect, useMemo, useRef, useState } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { Search, X } from \"lucide-react\";\nimport { SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { EASE } from \"./constants\";\nimport { ChainChip, TokenDot } from \"./token-badges\";\nimport type { Chain, Token, TokenSide } from \"./types\";\nimport { formatAmount } from \"./utils\";\n\nexport function TokenPicker({\n open,\n side,\n chains,\n tokens,\n selectedId,\n onPick,\n onClose,\n reduce,\n}: {\n open: boolean;\n side: TokenSide | null;\n chains: Chain[];\n tokens: Token[];\n selectedId: string;\n onPick: (id: string) => void;\n onClose: () => void;\n reduce: boolean;\n}) {\n const [chainFilter, setChainFilter] = useState(\"all\");\n const [q, setQ] = useState(\"\");\n const inputRef = useRef(null);\n\n useEffect(() => {\n if (!open) return;\n setQ(\"\");\n requestAnimationFrame(() =>\n inputRef.current?.focus({ preventScroll: true }),\n );\n }, [open]);\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") onClose();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [open, onClose]);\n\n const popular = useMemo(\n () => tokens.filter((t) => t.popular).slice(0, 6),\n [tokens],\n );\n const chainById = useMemo(\n () => new Map(chains.map((chain) => [chain.id, chain])),\n [chains],\n );\n\n const filtered = useMemo(() => {\n const needle = q.trim().toLowerCase();\n return tokens.filter((t) => {\n if (chainFilter !== \"all\" && t.chainId !== chainFilter) return false;\n if (!needle) return true;\n const chain = chains.find((c) => c.id === t.chainId);\n return [t.symbol, t.name, chain?.name, t.address].some((h) =>\n h?.toLowerCase().includes(needle),\n );\n });\n }, [q, chainFilter, tokens, chains]);\n\n return (\n \n {open ? (\n <>\n \n\n \n
\n \n
\n\n
\n \n {/* oxlint-disable-next-line jsx-a11y/control-has-associated-label */}\n setQ(e.target.value)}\n placeholder=\"Search name or paste address\"\n className=\"w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/70\"\n />\n \n \n \n
\n\n
\n setChainFilter(\"all\")}\n label=\"All\"\n />\n {chains.map((c) => (\n setChainFilter(c.id)}\n chain={c}\n />\n ))}\n
\n\n
\n {!q && chainFilter === \"all\" ? (\n <>\n

\n Most popular\n

\n
\n {popular.map((t) => {\n const chain = chainById.get(t.chainId);\n if (!chain) return null;\n return (\n onPick(t.id)}\n className=\"inline-flex shrink-0 items-center gap-2 rounded-full border border-border bg-background/50 py-1 pl-1 pr-3 text-sm font-semibold text-foreground transition-transform hover:border-border active:scale-[0.97]\"\n >\n \n {t.symbol}\n \n );\n })}\n
\n \n ) : null}\n\n

\n {q ? \"Results\" : chainFilter === \"all\" ? \"Trending\" : \"Tokens\"}\n

\n
    \n {filtered.length === 0 ? (\n
  • \n No tokens found\n
  • \n ) : null}\n {filtered.map((t) => {\n const chain = chainById.get(t.chainId);\n if (!chain) return null;\n const active = t.id === selectedId;\n return (\n
  • \n onPick(t.id)}\n className={cn(\n \"flex w-full items-center justify-between rounded-xl px-2 py-2 text-left transition-colors active:scale-[0.97]\",\n active\n ? \"bg-muted/60\"\n : \"hover:bg-muted/60\",\n )}\n >\n \n \n \n \n {t.name}\n \n \n {t.symbol}\n \n \n \n \n {t.address ??\n (t.balance ? formatAmount(t.balance) : \"\")}\n \n \n
  • \n );\n })}\n
\n
\n \n \n ) : null}\n
\n );\n}\n" }, { "path": "components/motion/swap/types.ts", "type": "registry:component", "target": "components/motion/swap/types.ts", "content": "export type Chain = {\n id: string;\n name: string;\n tone: string;\n symbol: string;\n};\n\nexport type Token = {\n id: string;\n symbol: string;\n name: string;\n chainId: string;\n address?: string;\n balance?: number;\n usd?: number;\n trending?: boolean;\n popular?: boolean;\n};\n\nexport type TokenSide = \"from\" | \"to\";\n" }, { "path": "components/motion/swap/utils.ts", "type": "registry:component", "target": "components/motion/swap/utils.ts", "content": "export function isValidAddress(v: string) {\n if (/^0x[0-9a-fA-F]{40}$/.test(v)) return true;\n if (/\\.(eth|sol|bnb)$/.test(v) && v.length > 5) return true;\n return false;\n}\n\nexport function truncateAddress(v: string) {\n if (v.startsWith(\"0x\") && v.length === 42) {\n return `${v.slice(0, 6)}...${v.slice(-4)}`;\n }\n return v;\n}\n\nexport function sanitizeAmount(v: string) {\n const cleaned = v.replace(/[^0-9.]/g, \"\");\n const parts = cleaned.split(\".\");\n if (parts.length <= 1) return cleaned;\n return `${parts[0]}.${parts.slice(1).join(\"\")}`;\n}\n\nexport function formatAmount(n: number, max = 6) {\n if (!Number.isFinite(n)) return \"0\";\n if (n === 0) return \"0\";\n if (n >= 1000) {\n return n.toLocaleString(undefined, { maximumFractionDigits: 2 });\n }\n return n.toLocaleString(undefined, { maximumFractionDigits: max });\n}\n" }, { "path": "components/motion/swap/constants.ts", "type": "registry:component", "target": "components/motion/swap/constants.ts", "content": "// Swap-local aliases for the shared motion tokens in @/lib/ease.\nexport { EASE_OUT as EASE, EASE_DRAWER as SWAP_DRAWER_EASE } from \"@/lib/ease\";\n" }, { "path": "components/motion/swap/token-badges.tsx", "type": "registry:component", "target": "components/motion/swap/token-badges.tsx", "content": "import { cn } from \"@/lib/utils\";\nimport type { Chain, Token } from \"./types\";\n\nexport function ChainChip({\n active,\n onClick,\n chain,\n label,\n}: {\n active: boolean;\n onClick: () => void;\n chain?: Chain;\n label?: string;\n}) {\n return (\n \n {chain ? (\n \n ) : (\n {label}\n )}\n \n );\n}\n\nexport function ChainDot({ chain, size = 16 }: { chain: Chain; size?: number }) {\n return (\n \n {chain.symbol}\n \n );\n}\n\nexport function TokenDot({\n token,\n chain,\n size = 28,\n}: {\n token: Token;\n chain: Chain;\n size?: number;\n}) {\n return (\n \n \n {token.symbol.slice(0, 2)}\n \n \n {chain.symbol}\n \n \n );\n}\n" } ], "categories": [ "motion-kit" ], "meta": { "group": "motion", "docs": "https://dxuireact.com/docs/components/swap" } }