{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"swap","type":"registry:block","title":"Multi-chain Swap","description":"Cross-chain swap widget with chain + token selectors, morphing views, animated flip and quote.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/swap.tsx","type":"registry:component","target":"@components/motion/swap.tsx","content":"\"use client\";\n\nimport { 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<TokenSide | null>(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    <div\n      className={cn(\n        \"relative isolate w-full max-w-[420px] overflow-hidden rounded-3xl\",\n        \"border border-border/20 bg-card\",\n        className,\n      )}\n    >\n      <div className=\"flex h-12 items-center justify-between border-b border-border/50 px-3\">\n        <span className=\"px-2 text-sm font-semibold tracking-tight text-foreground\">\n          Swap\n        </span>\n        <button\n          type=\"button\"\n          aria-label=\"Settings\"\n          className=\"inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-transform hover:bg-primary/5 hover:text-foreground active:scale-[0.97]\"\n        >\n          <Settings className=\"h-4 w-4\" />\n        </button>\n      </div>\n\n      <div className=\"flex flex-col gap-1.5 p-4\">\n        <Field\n          side=\"from\"\n          token={from}\n          chain={fromChain}\n          amount={amount}\n          onAmount={setAmount}\n          editable\n          quoting={false}\n          onOpenPicker={() => setPicking(\"from\")}\n        />\n\n        <FlipButton rotation={flipRot} reduce={!!reduce} onClick={flip} />\n\n        <Field\n          side=\"to\"\n          token={to}\n          chain={toChain}\n          amount={toAmount > 0 ? formatAmount(toAmount) : \"\"}\n          editable={false}\n          quoting={quoting}\n          onOpenPicker={() => setPicking(\"to\")}\n        />\n\n        <QuoteRow\n          from={from}\n          to={to}\n          rate={rate}\n          fee={0.42}\n          slippage={0.5}\n          eta=\"≈ 24s\"\n          quoting={quoting}\n        />\n\n        <DestinationRow\n          show={showDest}\n          onToggle={() => {\n            if (showDest) setDestAddress(\"\");\n            setShowDest((v) => !v);\n          }}\n          address={destAddress}\n          onAddress={setDestAddress}\n          reduce={!!reduce}\n        />\n\n        <ActionButton\n          from={from}\n          to={to}\n          amount={numericAmount}\n          destAddress={destAddress}\n        />\n      </div>\n\n      <TokenPicker\n        open={picking !== null}\n        side={picking}\n        chains={chains}\n        tokens={tokens}\n        selectedId={picking === \"from\" ? fromId : toId}\n        onPick={pickToken}\n        onClose={() => setPicking(null)}\n        reduce={!!reduce}\n      />\n    </div>\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/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/controls.tsx","type":"registry:component","target":"@components/motion/swap/controls.tsx","content":"\"use client\";\n\nimport { 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    <div className=\"relative -my-4 flex justify-center\" style={{ zIndex: 1 }}>\n      <motion.button\n        type=\"button\"\n        onClick={onClick}\n        aria-label=\"Reverse direction\"\n        whileTap={reduce ? undefined : { scale: 0.9 }}\n        animate={reduce ? undefined : { rotate: rotation }}\n        transition={{ type: \"spring\", stiffness: 380, damping: 26, mass: 0.6 }}\n        className=\"inline-flex h-9 w-9 items-center justify-center rounded-full border-[3px] border-card bg-primary/10 text-foreground backdrop-blur\"\n      >\n        <ArrowDownUp className=\"h-3.5 w-3.5\" />\n      </motion.button>\n    </div>\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    <motion.button\n      type=\"button\"\n      whileTap={disabled || reduce ? undefined : { scale: 0.97 }}\n      transition={SPRING_PRESS}\n      disabled={disabled}\n      className={cn(\n        \"mt-3 inline-flex h-12 w-full items-center justify-center rounded-2xl text-sm font-semibold transition-colors\",\n        disabled\n          ? \"cursor-not-allowed bg-primary/10 text-muted-foreground\"\n          : \"bg-primary text-primary-foreground hover:bg-primary/90\",\n      )}\n    >\n      <AnimatePresence mode=\"wait\" initial={false}>\n        <motion.span\n          key={label}\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0 }}\n          transition={{ duration: 0.14, ease: EASE }}\n        >\n          {label}\n        </motion.span>\n      </AnimatePresence>\n    </motion.button>\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<HTMLInputElement>(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    <div className=\"mt-1 overflow-hidden rounded-xl border border-border/50 bg-background/40\">\n      <button\n        type=\"button\"\n        onClick={onToggle}\n        className=\"flex w-full items-center justify-between gap-2 px-3.5 py-2.5 text-[12px] text-muted-foreground hover:text-foreground\"\n      >\n        <span className=\"flex items-center gap-2\">\n          <Send className=\"h-3.5 w-3.5 shrink-0\" />\n          <span>\n            {show && hasAddress && valid\n              ? `To: ${truncateAddress(address)}`\n              : \"Send to different address\"}\n          </span>\n        </span>\n        <motion.span\n          animate={reduce ? {} : { rotate: show ? 180 : 0 }}\n          transition={{ duration: 0.2, ease: EASE }}\n          style={{ display: \"inline-flex\" }}\n        >\n          <ChevronDown className=\"h-3.5 w-3.5\" />\n        </motion.span>\n      </button>\n\n      <AnimatePresence>\n        {show ? (\n          <motion.div\n            key=\"dest-input\"\n            initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n            animate={reduce ? { opacity: 1 } : { height: \"auto\", opacity: 1 }}\n            exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n            transition={{ duration: 0.22, ease: EASE }}\n            style={{ overflow: \"hidden\" }}\n          >\n            <div className=\"border-t border-border/50 px-3.5 pb-3 pt-2.5\">\n              <div\n                className={cn(\n                  \"flex items-center gap-2 rounded-lg border px-2.5 py-2 transition-colors\",\n                  hasAddress && !valid\n                    ? \"border-destructive/40\"\n                    : \"border-border\",\n                )}\n              >\n                <input\n                  ref={inputRef}\n                  value={address}\n                  onChange={(e) => 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                <AnimatePresence mode=\"wait\">\n                  {hasAddress ? (\n                    valid ? (\n                      <motion.span\n                        key=\"check\"\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                      >\n                        <Check className=\"h-3.5 w-3.5 shrink-0 text-primary\" />\n                      </motion.span>\n                    ) : (\n                      <motion.button\n                        key=\"clear\"\n                        type=\"button\"\n                        onClick={() => 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                        <X className=\"h-3.5 w-3.5\" />\n                      </motion.button>\n                    )\n                  ) : null}\n                </AnimatePresence>\n              </div>\n            </div>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\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/field.tsx","type":"registry:component","target":"@components/motion/swap/field.tsx","content":"\"use client\";\n\nimport { 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    <div className=\"relative rounded-2xl border border-border/50 bg-background/40 p-3.5\">\n      <label\n        htmlFor={id}\n        className=\"mb-2 block text-[11px] font-medium uppercase tracking-wider text-muted-foreground\"\n      >\n        {side === \"from\" ? \"You pay\" : \"You get\"}\n      </label>\n\n      <div className=\"flex items-center justify-between gap-3\">\n        <div className=\"min-w-0 flex-1\">\n          {editable ? (\n            <input\n              id={id}\n              inputMode=\"decimal\"\n              value={amount}\n              onChange={(e) => 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            <div className=\"flex h-9 items-center gap-2 text-2xl font-semibold tracking-tight tabular-nums\">\n              <motion.span\n                animate={{\n                  opacity: quoting ? 0.55 : 1,\n                  filter: quoting ? \"blur(2px)\" : \"blur(0px)\",\n                }}\n                transition={{ duration: 0.18, ease: EASE }}\n                className=\"text-foreground\"\n              >\n                {amount || \"0\"}\n              </motion.span>\n              <AnimatePresence>\n                {quoting ? (\n                  <motion.span\n                    initial={{ opacity: 0, scale: 0.85 }}\n                    animate={{ opacity: 1, scale: 1 }}\n                    exit={{ opacity: 0, scale: 0.85 }}\n                    transition={{ duration: 0.14, ease: EASE }}\n                  >\n                    <Loader2 className=\"h-4 w-4 animate-spin text-muted-foreground\" />\n                  </motion.span>\n                ) : null}\n              </AnimatePresence>\n            </div>\n          )}\n          <p className=\"mt-1 text-[11px] text-muted-foreground tabular-nums\">\n            ≈ ${formatAmount(usdValue, 2)}\n          </p>\n        </div>\n\n        <button\n          type=\"button\"\n          onClick={onOpenPicker}\n          className=\"group inline-flex h-10 items-center gap-2 rounded-full border border-border bg-card pl-1 pr-2.5 text-sm font-semibold text-foreground transition-transform hover:border-border active:scale-[0.97]\"\n        >\n          <TokenDot token={token} chain={chain} />\n          <span>{token.symbol}</span>\n          <ChevronDown className=\"h-3.5 w-3.5 text-muted-foreground\" />\n        </button>\n      </div>\n\n      <div className=\"mt-2 flex items-center justify-between text-[11px] text-muted-foreground\">\n        <span className=\"inline-flex items-center gap-1\">\n          <Wallet className=\"h-3 w-3\" />\n          <span className=\"tabular-nums\">\n            {token.balance ? formatAmount(token.balance) : \"0.00\"}\n          </span>\n          <span>· {chain.name}</span>\n        </span>\n        {side === \"from\" && token.balance ? (\n          <button\n            type=\"button\"\n            onClick={() => 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-primary/5 hover:text-foreground\"\n          >\n            Max\n          </button>\n        ) : null}\n      </div>\n    </div>\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    <div className=\"mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5 rounded-xl border border-border/50 bg-background/40 px-3.5 py-2.5 text-[11px]\">\n      <span className=\"text-muted-foreground\">Rate</span>\n      <span className=\"text-right tabular-nums text-foreground\">\n        {quoting ? (\n          <Loader2 className=\"ml-auto inline h-3 w-3 animate-spin text-muted-foreground\" />\n        ) : (\n          <>\n            1 {from.symbol} ≈ {formatAmount(rate)} {to.symbol}\n          </>\n        )}\n      </span>\n      <span className=\"text-muted-foreground\">Network fee</span>\n      <span className=\"text-right tabular-nums text-foreground\">\n        ${fee.toFixed(2)}\n      </span>\n      <span className=\"text-muted-foreground\">Slippage</span>\n      <span className=\"text-right tabular-nums text-foreground\">\n        {slippage.toFixed(2)}%\n      </span>\n      <span className=\"text-muted-foreground\">ETA</span>\n      <span className=\"text-right text-foreground\">{eta}</span>\n    </div>\n  );\n}\n"},{"path":"components/motion/swap/token-picker.tsx","type":"registry:component","target":"@components/motion/swap/token-picker.tsx","content":"\"use client\";\n\nimport { 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<HTMLInputElement>(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    <AnimatePresence>\n      {open ? (\n        <>\n          <motion.button\n            key=\"backdrop\"\n            type=\"button\"\n            aria-label=\"Close\"\n            onClick={onClose}\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            transition={{ duration: 0.2, ease: EASE }}\n            className=\"absolute inset-0 z-10 cursor-default bg-background/40 backdrop-blur-sm\"\n          />\n\n          <motion.div\n            key=\"sheet\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: \"100%\" }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: \"100%\" }}\n            transition={\n              reduce ? { duration: 0.18, ease: EASE } : SPRING_PANEL\n            }\n            className=\"absolute inset-x-0 bottom-0 z-20 flex max-h-[92%] flex-col rounded-t-3xl border-t border-border bg-card shadow-2xl\"\n            role=\"dialog\"\n            aria-modal=\"true\"\n            aria-label={`Select ${side === \"from\" ? \"from\" : \"to\"} token`}\n          >\n            <div className=\"flex justify-center pb-1 pt-2.5\">\n              <span className=\"h-1 w-9 rounded-full bg-primary/15\" />\n            </div>\n\n            <div className=\"flex items-center gap-2 border-b border-border px-4 pb-3\">\n              <Search className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n              <input\n                ref={inputRef}\n                value={q}\n                onChange={(e) => 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              <button\n                type=\"button\"\n                onClick={onClose}\n                aria-label=\"Close\"\n                className=\"inline-flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground hover:bg-primary/5 hover:text-foreground\"\n              >\n                <X className=\"h-3.5 w-3.5\" />\n              </button>\n            </div>\n\n            <div className=\"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden flex items-center gap-1.5 overflow-x-auto border-b border-border px-3 py-5\">\n              <ChainChip\n                active={chainFilter === \"all\"}\n                onClick={() => setChainFilter(\"all\")}\n                label=\"All\"\n              />\n              {chains.map((c) => (\n                <ChainChip\n                  key={c.id}\n                  active={chainFilter === c.id}\n                  onClick={() => setChainFilter(c.id)}\n                  chain={c}\n                />\n              ))}\n            </div>\n\n            <div className=\"flex-1 overflow-y-auto px-3 pb-4 pt-3\">\n              {!q && chainFilter === \"all\" ? (\n                <>\n                  <p className=\"px-1 pb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground\">\n                    Most popular\n                  </p>\n                  <div className=\"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden flex items-center gap-1.5 overflow-x-auto pb-3\">\n                    {popular.map((t) => {\n                      const chain = chainById.get(t.chainId);\n                      if (!chain) return null;\n                      return (\n                        <button\n                          key={t.id}\n                          type=\"button\"\n                          onClick={() => 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                          <TokenDot token={t} chain={chain} size={22} />\n                          {t.symbol}\n                        </button>\n                      );\n                    })}\n                  </div>\n                </>\n              ) : null}\n\n              <p className=\"px-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground\">\n                {q ? \"Results\" : chainFilter === \"all\" ? \"Trending\" : \"Tokens\"}\n              </p>\n              <ul className=\"flex flex-col gap-0.5\">\n                {filtered.length === 0 ? (\n                  <li className=\"py-8 text-center text-xs text-muted-foreground\">\n                    No tokens found\n                  </li>\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                    <li key={t.id}>\n                      <button\n                        type=\"button\"\n                        onClick={() => 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-primary/5\"\n                            : \"hover:bg-primary/[0.04]\",\n                        )}\n                      >\n                        <span className=\"flex min-w-0 items-center gap-2.5\">\n                          <TokenDot token={t} chain={chain} size={32} />\n                          <span className=\"flex min-w-0 flex-col\">\n                            <span className=\"truncate text-sm font-semibold text-foreground\">\n                              {t.name}\n                            </span>\n                            <span className=\"truncate text-[11px] text-muted-foreground\">\n                              {t.symbol}\n                            </span>\n                          </span>\n                        </span>\n                        <span className=\"shrink-0 text-right text-[11px] tabular-nums text-muted-foreground\">\n                          {t.address ??\n                            (t.balance ? formatAmount(t.balance) : \"\")}\n                        </span>\n                      </button>\n                    </li>\n                  );\n                })}\n              </ul>\n            </div>\n          </motion.div>\n        </>\n      ) : null}\n    </AnimatePresence>\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":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\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    <button\n      type=\"button\"\n      onClick={onClick}\n      aria-pressed={active}\n      className={cn(\n        \"inline-flex h-9 shrink-0 items-center justify-center rounded-xl border transition-colors active:scale-[0.97] transition-transform\",\n        chain ? \"w-9\" : \"px-3\",\n        active\n          ? \"border-primary/20 bg-primary/5 text-foreground\"\n          : \"border-border/60 bg-background/40 text-foreground hover:border-border\",\n      )}\n      title={chain?.name ?? label}\n    >\n      {chain ? (\n        <ChainDot chain={chain} size={20} />\n      ) : (\n        <span className=\"text-xs font-semibold\">{label}</span>\n      )}\n    </button>\n  );\n}\n\nexport function ChainDot({ chain, size = 16 }: { chain: Chain; size?: number }) {\n  return (\n    <span\n      style={{ width: size, height: size }}\n      className={cn(\n        \"inline-flex shrink-0 items-center justify-center rounded-full text-[9px] font-bold\",\n        chain.tone,\n      )}\n    >\n      {chain.symbol}\n    </span>\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    <span\n      className=\"relative inline-flex shrink-0\"\n      style={{ width: size, height: size }}\n    >\n      <span className=\"absolute inset-0 inline-flex items-center justify-center rounded-full border border-border bg-background text-[11px] font-bold text-foreground\">\n        {token.symbol.slice(0, 2)}\n      </span>\n      <span\n        style={{\n          width: size * 0.42,\n          height: size * 0.42,\n        }}\n        className={cn(\n          \"absolute -bottom-0.5 -right-0.5 inline-flex items-center justify-center rounded-full border-2 border-card text-[7px] font-bold\",\n          chain.tone,\n        )}\n      >\n        {chain.symbol}\n      </span>\n    </span>\n  );\n}\n"}]}