Multi-chain Swap
Cross-chain swap widget with chain + token selectors, morphing views, animated flip and quote.
Preview
≈ $3,142
≈ $3,142
"use client";
import { MultiChainSwap } from "@/components/motion/swap";
export function SwapPreview() {
return (
<div className="flex w-full items-center justify-center">
<MultiChainSwap />
</div>
);
}
"use client";
// beui.dev/components/blocks/swap
import { useEffect, useMemo, useState } from "react";
import { Settings } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { CHAINS, TOKENS } from "./swap/data";
import { ActionButton, DestinationRow, FlipButton } from "./swap/controls";
import { Field } from "./swap/field";
import { QuoteRow } from "./swap/quote-row";
import { TokenPicker } from "./swap/token-picker";
import type { Chain, Token, TokenSide } from "./swap/types";
import { formatAmount } from "./swap/utils";
export type { Chain, Token } from "./swap/types";
export { SWAP_DRAWER_EASE } from "./swap/constants";
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<TokenSide | null>(null);
const [showDest, setShowDest] = useState(false);
const [destAddress, setDestAddress] = useState("");
const from = findToken(tokens, fromId);
const to = findToken(tokens, toId);
const fromChain = findChain(chains, from.chainId);
const toChain = findChain(chains, to.chainId);
const numericAmount = Number(amount) || 0;
const quoteKey = `${numericAmount}:${fromId}:${toId}`;
const rate = useMemo(() => {
if (!from.usd || !to.usd) return 1;
return from.usd / to.usd;
}, [from.usd, to.usd]);
const toAmount = numericAmount * rate;
useEffect(() => {
if (!quoteKey) return;
if (numericAmount === 0) return;
setQuoting(true);
const id = setTimeout(() => setQuoting(false), 450);
return () => clearTimeout(id);
}, [numericAmount, quoteKey]);
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 (
<div
className={cn(
"relative isolate w-full max-w-[420px] overflow-hidden rounded-3xl",
"border border-border/20 bg-card",
className,
)}
>
<div className="flex h-12 items-center justify-between border-b border-border/50 px-3">
<span className="px-2 text-sm font-semibold tracking-tight text-foreground">
Swap
</span>
<button
type="button"
aria-label="Settings"
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]"
>
<Settings className="h-4 w-4" />
</button>
</div>
<div className="flex flex-col gap-1.5 p-4">
<Field
side="from"
token={from}
chain={fromChain}
amount={amount}
onAmount={setAmount}
editable
quoting={false}
onOpenPicker={() => setPicking("from")}
/>
<FlipButton rotation={flipRot} reduce={!!reduce} onClick={flip} />
<Field
side="to"
token={to}
chain={toChain}
amount={toAmount > 0 ? formatAmount(toAmount) : ""}
editable={false}
quoting={quoting}
onOpenPicker={() => setPicking("to")}
/>
<QuoteRow
from={from}
to={to}
rate={rate}
fee={0.42}
slippage={0.5}
eta="≈ 24s"
quoting={quoting}
/>
<DestinationRow
show={showDest}
onToggle={() => {
if (showDest) setDestAddress("");
setShowDest((v) => !v);
}}
address={destAddress}
onAddress={setDestAddress}
reduce={!!reduce}
/>
<ActionButton
from={from}
to={to}
amount={numericAmount}
destAddress={destAddress}
/>
</div>
<TokenPicker
open={picking !== null}
side={picking}
chains={chains}
tokens={tokens}
selectedId={picking === "from" ? fromId : toId}
onPick={pickToken}
onClose={() => setPicking(null)}
reduce={!!reduce}
/>
</div>
);
}
function findToken(tokens: Token[], id: string) {
const token = tokens.find((t) => t.id === id);
if (!token) throw new Error(`Unknown token id: ${id}`);
return token;
}
function findChain(chains: Chain[], id: string) {
const chain = chains.find((c) => c.id === id);
if (!chain) throw new Error(`Unknown chain id: ${id}`);
return chain;
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
Copy the source code
"use client";
// beui.dev/components/blocks/swap
import { useEffect, useMemo, useState } from "react";
import { Settings } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { CHAINS, TOKENS } from "./swap/data";
import { ActionButton, DestinationRow, FlipButton } from "./swap/controls";
import { Field } from "./swap/field";
import { QuoteRow } from "./swap/quote-row";
import { TokenPicker } from "./swap/token-picker";
import type { Chain, Token, TokenSide } from "./swap/types";
import { formatAmount } from "./swap/utils";
export type { Chain, Token } from "./swap/types";
export { SWAP_DRAWER_EASE } from "./swap/constants";
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<TokenSide | null>(null);
const [showDest, setShowDest] = useState(false);
const [destAddress, setDestAddress] = useState("");
const from = findToken(tokens, fromId);
const to = findToken(tokens, toId);
const fromChain = findChain(chains, from.chainId);
const toChain = findChain(chains, to.chainId);
const numericAmount = Number(amount) || 0;
const quoteKey = `${numericAmount}:${fromId}:${toId}`;
const rate = useMemo(() => {
if (!from.usd || !to.usd) return 1;
return from.usd / to.usd;
}, [from.usd, to.usd]);
const toAmount = numericAmount * rate;
useEffect(() => {
if (!quoteKey) return;
if (numericAmount === 0) return;
setQuoting(true);
const id = setTimeout(() => setQuoting(false), 450);
return () => clearTimeout(id);
}, [numericAmount, quoteKey]);
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 (
<div
className={cn(
"relative isolate w-full max-w-[420px] overflow-hidden rounded-3xl",
"border border-border/20 bg-card",
className,
)}
>
<div className="flex h-12 items-center justify-between border-b border-border/50 px-3">
<span className="px-2 text-sm font-semibold tracking-tight text-foreground">
Swap
</span>
<button
type="button"
aria-label="Settings"
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]"
>
<Settings className="h-4 w-4" />
</button>
</div>
<div className="flex flex-col gap-1.5 p-4">
<Field
side="from"
token={from}
chain={fromChain}
amount={amount}
onAmount={setAmount}
editable
quoting={false}
onOpenPicker={() => setPicking("from")}
/>
<FlipButton rotation={flipRot} reduce={!!reduce} onClick={flip} />
<Field
side="to"
token={to}
chain={toChain}
amount={toAmount > 0 ? formatAmount(toAmount) : ""}
editable={false}
quoting={quoting}
onOpenPicker={() => setPicking("to")}
/>
<QuoteRow
from={from}
to={to}
rate={rate}
fee={0.42}
slippage={0.5}
eta="≈ 24s"
quoting={quoting}
/>
<DestinationRow
show={showDest}
onToggle={() => {
if (showDest) setDestAddress("");
setShowDest((v) => !v);
}}
address={destAddress}
onAddress={setDestAddress}
reduce={!!reduce}
/>
<ActionButton
from={from}
to={to}
amount={numericAmount}
destAddress={destAddress}
/>
</div>
<TokenPicker
open={picking !== null}
side={picking}
chains={chains}
tokens={tokens}
selectedId={picking === "from" ? fromId : toId}
onPick={pickToken}
onClose={() => setPicking(null)}
reduce={!!reduce}
/>
</div>
);
}
function findToken(tokens: Token[], id: string) {
const token = tokens.find((t) => t.id === id);
if (!token) throw new Error(`Unknown token id: ${id}`);
return token;
}
function findChain(chains: Chain[], id: string) {
const chain = chains.find((c) => c.id === id);
if (!chain) throw new Error(`Unknown chain id: ${id}`);
return chain;
}
// Swap-local aliases for the shared motion tokens in @/lib/ease.
export { EASE_OUT as EASE, EASE_DRAWER as SWAP_DRAWER_EASE } from "@/lib/ease";
"use client";
import { useEffect, useRef } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowDownUp, Check, ChevronDown, Send, X } from "lucide-react";
import { SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
import type { Token } from "./types";
import { EASE } from "./constants";
import { isValidAddress, truncateAddress } from "./utils";
export function FlipButton({
rotation,
reduce,
onClick,
}: {
rotation: number;
reduce: boolean;
onClick: () => void;
}) {
return (
<div className="relative -my-4 flex justify-center" style={{ zIndex: 1 }}>
<motion.button
type="button"
onClick={onClick}
aria-label="Reverse direction"
whileTap={reduce ? undefined : { scale: 0.9 }}
animate={reduce ? undefined : { rotate: rotation }}
transition={{ type: "spring", stiffness: 380, damping: 26, mass: 0.6 }}
className="inline-flex h-9 w-9 items-center justify-center rounded-full border-[3px] border-card bg-primary/10 text-foreground backdrop-blur"
>
<ArrowDownUp className="h-3.5 w-3.5" />
</motion.button>
</div>
);
}
export function ActionButton({
from,
to,
amount,
destAddress,
}: {
from: Token;
to: Token;
amount: number;
destAddress: string;
}) {
const reduce = useReducedMotion();
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 (
<motion.button
type="button"
whileTap={disabled || reduce ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
disabled={disabled}
className={cn(
"mt-3 inline-flex h-12 w-full items-center justify-center rounded-2xl text-sm font-semibold transition-colors",
disabled
? "cursor-not-allowed bg-primary/10 text-muted-foreground"
: "bg-primary text-primary-foreground hover:bg-primary/90",
)}
>
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={label}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.14, ease: EASE }}
>
{label}
</motion.span>
</AnimatePresence>
</motion.button>
);
}
export function DestinationRow({
show,
onToggle,
address,
onAddress,
reduce,
}: {
show: boolean;
onToggle: () => void;
address: string;
onAddress: (v: string) => void;
reduce: boolean;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const hasAddress = address.length > 0;
const valid = isValidAddress(address);
useEffect(() => {
if (!show) return;
requestAnimationFrame(() =>
inputRef.current?.focus({ preventScroll: true }),
);
}, [show]);
return (
<div className="mt-1 overflow-hidden rounded-xl border border-border/50 bg-background/40">
<button
type="button"
onClick={onToggle}
className="flex w-full items-center justify-between gap-2 px-3.5 py-2.5 text-[12px] text-muted-foreground hover:text-foreground"
>
<span className="flex items-center gap-2">
<Send className="h-3.5 w-3.5 shrink-0" />
<span>
{show && hasAddress && valid
? `To: ${truncateAddress(address)}`
: "Send to different address"}
</span>
</span>
<motion.span
animate={reduce ? {} : { rotate: show ? 180 : 0 }}
transition={{ duration: 0.2, ease: EASE }}
style={{ display: "inline-flex" }}
>
<ChevronDown className="h-3.5 w-3.5" />
</motion.span>
</button>
<AnimatePresence>
{show ? (
<motion.div
key="dest-input"
initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
animate={reduce ? { opacity: 1 } : { height: "auto", opacity: 1 }}
exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={{ duration: 0.22, ease: EASE }}
style={{ overflow: "hidden" }}
>
<div className="border-t border-border/50 px-3.5 pb-3 pt-2.5">
<div
className={cn(
"flex items-center gap-2 rounded-lg border px-2.5 py-2 transition-colors",
hasAddress && !valid
? "border-destructive/40"
: "border-border",
)}
>
<input
ref={inputRef}
value={address}
onChange={(e) => onAddress(e.target.value.trim())}
placeholder="0x... or name.eth"
spellCheck={false}
className="min-w-0 flex-1 bg-transparent font-mono text-[12px] text-foreground outline-none placeholder:text-muted-foreground/60"
/>
<AnimatePresence mode="wait">
{hasAddress ? (
valid ? (
<motion.span
key="check"
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.7 }}
transition={{ duration: 0.14, ease: EASE }}
>
<Check className="h-3.5 w-3.5 shrink-0 text-primary" />
</motion.span>
) : (
<motion.button
key="clear"
type="button"
onClick={() => onAddress("")}
aria-label="Clear address"
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.7 }}
transition={{ duration: 0.14, ease: EASE }}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</motion.button>
)
) : null}
</AnimatePresence>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
import type { Chain, Token } from "./types";
export const CHAINS: Chain[] = [
{ id: "eth", name: "Ethereum", tone: "bg-primary text-primary-foreground", symbol: "Ξ" },
{ id: "sol", name: "Solana", tone: "bg-secondary text-secondary-foreground", symbol: "◎" },
{ id: "base", name: "Base", tone: "bg-accent text-accent-foreground", symbol: "B" },
{ id: "arb", name: "Arbitrum", tone: "bg-muted text-muted-foreground", symbol: "A" },
{ id: "op", name: "Optimism", tone: "bg-destructive text-primary-foreground", symbol: "O" },
{ id: "poly", name: "Polygon", tone: "bg-primary/80 text-primary-foreground", symbol: "P" },
{ id: "bnb", name: "BNB", tone: "bg-secondary text-secondary-foreground", symbol: "B" },
{ id: "avax", name: "Avalanche", tone: "bg-destructive/80 text-primary-foreground", symbol: "A" },
];
export 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,
},
];
"use client";
import { useId } from "react";
import { AnimatePresence, motion } from "motion/react";
import { ChevronDown, Loader2, Wallet } from "lucide-react";
import type { Chain, Token, TokenSide } from "./types";
import { TokenDot } from "./token-badges";
import { EASE } from "./constants";
import { formatAmount, sanitizeAmount } from "./utils";
export function Field({
side,
token,
chain,
amount,
onAmount,
editable,
quoting,
onOpenPicker,
}: {
side: TokenSide;
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 (
<div className="relative rounded-2xl border border-border/50 bg-background/40 p-3.5">
<label
htmlFor={id}
className="mb-2 block text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
>
{side === "from" ? "You pay" : "You get"}
</label>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
{editable ? (
<input
id={id}
inputMode="decimal"
value={amount}
onChange={(e) => onAmount?.(sanitizeAmount(e.target.value))}
placeholder="0"
className="w-full bg-transparent text-2xl font-semibold tracking-tight text-foreground tabular-nums outline-none placeholder:text-muted-foreground/60"
/>
) : (
<div className="flex h-9 items-center gap-2 text-2xl font-semibold tracking-tight tabular-nums">
<motion.span
animate={{
opacity: quoting ? 0.55 : 1,
filter: quoting ? "blur(2px)" : "blur(0px)",
}}
transition={{ duration: 0.18, ease: EASE }}
className="text-foreground"
>
{amount || "0"}
</motion.span>
<AnimatePresence>
{quoting ? (
<motion.span
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
transition={{ duration: 0.14, ease: EASE }}
>
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</motion.span>
) : null}
</AnimatePresence>
</div>
)}
<p className="mt-1 text-[11px] text-muted-foreground tabular-nums">
≈ ${formatAmount(usdValue, 2)}
</p>
</div>
<button
type="button"
onClick={onOpenPicker}
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]"
>
<TokenDot token={token} chain={chain} />
<span>{token.symbol}</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
<div className="mt-2 flex items-center justify-between text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Wallet className="h-3 w-3" />
<span className="tabular-nums">
{token.balance ? formatAmount(token.balance) : "0.00"}
</span>
<span>· {chain.name}</span>
</span>
{side === "from" && token.balance ? (
<button
type="button"
onClick={() => onAmount?.(String(token.balance))}
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"
>
Max
</button>
) : null}
</div>
</div>
);
}
import { Loader2 } from "lucide-react";
import type { Token } from "./types";
import { formatAmount } from "./utils";
export function QuoteRow({
from,
to,
rate,
fee,
slippage,
eta,
quoting,
}: {
from: Token;
to: Token;
rate: number;
fee: number;
slippage: number;
eta: string;
quoting: boolean;
}) {
return (
<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]">
<span className="text-muted-foreground">Rate</span>
<span className="text-right tabular-nums text-foreground">
{quoting ? (
<Loader2 className="ml-auto inline h-3 w-3 animate-spin text-muted-foreground" />
) : (
<>
1 {from.symbol} ≈ {formatAmount(rate)} {to.symbol}
</>
)}
</span>
<span className="text-muted-foreground">Network fee</span>
<span className="text-right tabular-nums text-foreground">
${fee.toFixed(2)}
</span>
<span className="text-muted-foreground">Slippage</span>
<span className="text-right tabular-nums text-foreground">
{slippage.toFixed(2)}%
</span>
<span className="text-muted-foreground">ETA</span>
<span className="text-right text-foreground">{eta}</span>
</div>
);
}
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { Search, X } from "lucide-react";
import { SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { EASE } from "./constants";
import { ChainChip, TokenDot } from "./token-badges";
import type { Chain, Token, TokenSide } from "./types";
import { formatAmount } from "./utils";
export function TokenPicker({
open,
side,
chains,
tokens,
selectedId,
onPick,
onClose,
reduce,
}: {
open: boolean;
side: TokenSide | null;
chains: Chain[];
tokens: Token[];
selectedId: string;
onPick: (id: string) => void;
onClose: () => void;
reduce: boolean;
}) {
const [chainFilter, setChainFilter] = useState("all");
const [q, setQ] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
setQ("");
requestAnimationFrame(() =>
inputRef.current?.focus({ preventScroll: true }),
);
}, [open]);
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 chainById = useMemo(
() => new Map(chains.map((chain) => [chain.id, chain])),
[chains],
);
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 (
<AnimatePresence>
{open ? (
<>
<motion.button
key="backdrop"
type="button"
aria-label="Close"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: EASE }}
className="absolute inset-0 z-10 cursor-default bg-background/40 backdrop-blur-sm"
/>
<motion.div
key="sheet"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: "100%" }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: "100%" }}
transition={
reduce ? { duration: 0.18, ease: EASE } : SPRING_PANEL
}
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"
role="dialog"
aria-modal="true"
aria-label={`Select ${side === "from" ? "from" : "to"} token`}
>
<div className="flex justify-center pb-1 pt-2.5">
<span className="h-1 w-9 rounded-full bg-primary/15" />
</div>
<div className="flex items-center gap-2 border-b border-border px-4 pb-3">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search name or paste address"
className="w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/70"
/>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground hover:bg-primary/5 hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<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">
<ChainChip
active={chainFilter === "all"}
onClick={() => setChainFilter("all")}
label="All"
/>
{chains.map((c) => (
<ChainChip
key={c.id}
active={chainFilter === c.id}
onClick={() => setChainFilter(c.id)}
chain={c}
/>
))}
</div>
<div className="flex-1 overflow-y-auto px-3 pb-4 pt-3">
{!q && chainFilter === "all" ? (
<>
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Most popular
</p>
<div className="[scrollbar-width:none] [&::-webkit-scrollbar]:hidden flex items-center gap-1.5 overflow-x-auto pb-3">
{popular.map((t) => {
const chain = chainById.get(t.chainId);
if (!chain) return null;
return (
<button
key={t.id}
type="button"
onClick={() => onPick(t.id)}
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]"
>
<TokenDot token={t} chain={chain} size={22} />
{t.symbol}
</button>
);
})}
</div>
</>
) : null}
<p className="px-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{q ? "Results" : chainFilter === "all" ? "Trending" : "Tokens"}
</p>
<ul className="flex flex-col gap-0.5">
{filtered.length === 0 ? (
<li className="py-8 text-center text-xs text-muted-foreground">
No tokens found
</li>
) : null}
{filtered.map((t) => {
const chain = chainById.get(t.chainId);
if (!chain) return null;
const active = t.id === selectedId;
return (
<li key={t.id}>
<button
type="button"
onClick={() => onPick(t.id)}
className={cn(
"flex w-full items-center justify-between rounded-xl px-2 py-2 text-left transition-colors active:scale-[0.97]",
active
? "bg-primary/5"
: "hover:bg-primary/[0.04]",
)}
>
<span className="flex min-w-0 items-center gap-2.5">
<TokenDot token={t} chain={chain} size={32} />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-semibold text-foreground">
{t.name}
</span>
<span className="truncate text-[11px] text-muted-foreground">
{t.symbol}
</span>
</span>
</span>
<span className="shrink-0 text-right text-[11px] tabular-nums text-muted-foreground">
{t.address ??
(t.balance ? formatAmount(t.balance) : "")}
</span>
</button>
</li>
);
})}
</ul>
</div>
</motion.div>
</>
) : null}
</AnimatePresence>
);
}
export type Chain = {
id: string;
name: string;
tone: string;
symbol: string;
};
export type Token = {
id: string;
symbol: string;
name: string;
chainId: string;
address?: string;
balance?: number;
usd?: number;
trending?: boolean;
popular?: boolean;
};
export type TokenSide = "from" | "to";
export 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;
}
export function truncateAddress(v: string) {
if (v.startsWith("0x") && v.length === 42) {
return `${v.slice(0, 6)}...${v.slice(-4)}`;
}
return v;
}
export 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("")}`;
}
export function formatAmount(n: number, max = 6) {
if (!Number.isFinite(n)) return "0";
if (n === 0) return "0";
if (n >= 1000) {
return n.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
return n.toLocaleString(undefined, { maximumFractionDigits: max });
}
import { cn } from "@/lib/utils";
import type { Chain, Token } from "./types";
export function ChainChip({
active,
onClick,
chain,
label,
}: {
active: boolean;
onClick: () => void;
chain?: Chain;
label?: string;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
"inline-flex h-9 shrink-0 items-center justify-center rounded-xl border transition-colors active:scale-[0.97] transition-transform",
chain ? "w-9" : "px-3",
active
? "border-primary/20 bg-primary/5 text-foreground"
: "border-border/60 bg-background/40 text-foreground hover:border-border",
)}
title={chain?.name ?? label}
>
{chain ? (
<ChainDot chain={chain} size={20} />
) : (
<span className="text-xs font-semibold">{label}</span>
)}
</button>
);
}
export function ChainDot({ chain, size = 16 }: { chain: Chain; size?: number }) {
return (
<span
style={{ width: size, height: size }}
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full text-[9px] font-bold",
chain.tone,
)}
>
{chain.symbol}
</span>
);
}
export function TokenDot({
token,
chain,
size = 28,
}: {
token: Token;
chain: Chain;
size?: number;
}) {
return (
<span
className="relative inline-flex shrink-0"
style={{ width: size, height: size }}
>
<span className="absolute inset-0 inline-flex items-center justify-center rounded-full border border-border bg-background text-[11px] font-bold text-foreground">
{token.symbol.slice(0, 2)}
</span>
<span
style={{
width: size * 0.42,
height: size * 0.42,
}}
className={cn(
"absolute -bottom-0.5 -right-0.5 inline-flex items-center justify-center rounded-full border-2 border-card text-[7px] font-bold",
chain.tone,
)}
>
{chain.symbol}
</span>
</span>
);
}
API Reference
chains?Chain[][
{ id: "eth", name: "Ethereum", tone: "bg-primary text-primary-foreground", symbol: "Ξ" },
{ id: "sol", name: "Solana", tone: "bg-secondary text-secondary-foreground", symbol: "◎" },
{ id: "base", name: "Base", tone: "bg-accent text-accent-foreground", symbol: "B" },
{ id: "arb", name: "Arbitrum", tone: "bg-muted text-muted-foreground", symbol: "A" },
{ id: "op", name: "Optimism", tone: "bg-destructive text-primary-foreground", symbol: "O" },
{ id: "poly", name: "Polygon", tone: "bg-primary/80 text-primary-foreground", symbol: "P" },
{ id: "bnb", name: "BNB", tone: "bg-secondary text-secondary-foreground", symbol: "B" },
{ id: "avax", name: "Avalanche", tone: "bg-destructive/80 text-primary-foreground", symbol: "A" },
]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,
},
]defaultFromId?stringeth-ethdefaultToId?stringsol-solclassName?string—Related components
Wallet Card
Wallet overview card with an account switcher and search that morph open from their triggers, a cascading balance with a live change pill and privacy toggle, copy-address, and Send / Deposit / Swap / Buy actions.
Prediction Market
Animated market listing cards with outcome CTAs, probabilities and bookmarks, plus a trade ticket with buy/sell modes and rolling amount entry.
Dynamic Island
iOS-style island pill that morphs between live activity views with bouncy shell resize and blur crossfades.
Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated