Coin Swap
Pay and receive cards that trade places when you swap coins.
Preview
Installation
pnpm dlx shadcn@latest add https://makeui.dev/r/coin-swap.jsonDependencies
- motion
- lucide-react
Interaction
Type an amount and the receive side updates with a rolling number. Press the arrow and the two coin chips glide into each other's card. Click a chip to pick another coin. The Swap button shimmers while it works, confirms, trades the coins and updates both balances.
Props
| Prop | Type | Description |
|---|---|---|
| prices | Partial<Record<CoinSymbol, number>> | USD price per coin. Defaults to sample prices, so pass live data. |
| balances | Partial<Record<CoinSymbol, number>> | Starting balances for each coin. |
| defaultPay | CoinSymbol | Coin on the pay side. Defaults to USDT. |
| defaultReceive | CoinSymbol | Coin on the receive side. Defaults to ETH. |
| onSwap | (trade) => void | Called after a swap with { from, to, pay, receive }. |
| className | string | Extra classes merged onto the root element. |
How to use
import CoinSwap from "@/components/ui/coin-swap"
export function Example() {
return <CoinSwap prices={{ ETH: 3300 }} onSwap={(t) => console.log(t)} />
}Source code
components/ui/coin-swap.tsx
"use client"
import { useState } from "react"
import {
AnimatePresence,
LayoutGroup,
motion,
} from "motion/react"
import {
ArrowDownUp,
Check,
ChevronDown,
} from "lucide-react"
import { cn } from "@/lib/utils"
import {
COIN_META,
CoinIcon,
type CoinSymbol,
} from "./coin-icon"
type Prices = Record<CoinSymbol, number>
type Balances = Record<CoinSymbol, number>
export interface CoinSwapProps {
prices?: Partial<Prices>
balances?: Partial<Balances>
defaultPay?: CoinSymbol
defaultReceive?: CoinSymbol
onSwap?: (trade: {
from: CoinSymbol
to: CoinSymbol
pay: number
receive: number
}) => void
className?: string
}
const PRICES: Prices = {
USDT: 1,
ETH: 3300,
BTC: 67000,
SOL: 160,
}
const BALANCES: Balances = {
USDT: 4537.5,
ETH: 0,
BTC: 0.12,
SOL: 14,
}
const SYMBOLS = Object.keys(COIN_META) as CoinSymbol[]
const fmt = (n: number, digits = 4) =>
n.toLocaleString("en-US", {
maximumSignificantDigits: digits,
})
const usd = (n: number) =>
n.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})
function Chip({
symbol,
onPick,
}: {
symbol: CoinSymbol
onPick: (s: CoinSymbol) => void
}) {
return (
<motion.label
layoutId={`coin-chip-${symbol}`}
transition={{
type: "spring",
stiffness: 380,
damping: 34,
}}
className="relative flex shrink-0 cursor-pointer items-center gap-2 rounded-full border border-neutral-200 bg-neutral-100 py-2 pl-2 pr-3 text-sm font-medium text-neutral-900 transition-colors hover:bg-neutral-200 focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-blue-500 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
<CoinIcon symbol={symbol} />
{symbol}
<ChevronDown className="size-4 text-neutral-500 dark:text-neutral-400" />
<select
aria-label="Choose coin"
value={symbol}
onChange={(e) =>
onPick(e.target.value as CoinSymbol)
}
className="absolute inset-0 cursor-pointer opacity-0"
>
{SYMBOLS.map((s) => (
<option
key={s}
value={s}
className="bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100"
>
{COIN_META[s].name} ({s})
</option>
))}
</select>
</motion.label>
)
}
export default function CoinSwap({
prices,
balances: startBalances,
defaultPay = "USDT",
defaultReceive = "ETH",
onSwap,
className,
}: CoinSwapProps) {
const price = { ...PRICES, ...prices }
const [pay, setPay] =
useState<CoinSymbol>(defaultPay)
const [rec, setRec] =
useState<CoinSymbol>(defaultReceive)
const [amount, setAmount] = useState("164.23")
const [balances, setBalances] =
useState<Balances>({
...BALANCES,
...startBalances,
})
const [turn, setTurn] = useState(0)
const [status, setStatus] =
useState<"idle" | "loading" | "done">("idle")
const n = Number.parseFloat(amount) || 0
const rate = price[pay] / price[rec]
const receive = n * rate
const value = n * price[pay]
const over = n > balances[pay]
const flip = () => {
setPay(rec)
setRec(pay)
setAmount(
receive ? String(+receive.toPrecision(6)) : ""
)
setTurn((t) => t + 1)
}
const pickPay = (s: CoinSymbol) =>
s === rec ? flip() : setPay(s)
const pickRec = (s: CoinSymbol) =>
s === pay ? flip() : setRec(s)
const swap = () => {
if (
status !== "idle" ||
!n ||
over ||
pay === rec
) {
return
}
setStatus("loading")
setTimeout(() => {
setBalances((b) => ({
...b,
[pay]: b[pay] - n,
[rec]: b[rec] + receive,
}))
onSwap?.({
from: pay,
to: rec,
pay: n,
receive,
})
setPay(rec)
setRec(pay)
setAmount(
String(Math.floor(receive * 1e6) / 1e6)
)
setTurn((t) => t + 1)
setStatus("done")
setTimeout(() => setStatus("idle"), 1600)
}, 1100)
}
const label =
status === "loading"
? "Swapping"
: status === "done"
? "Swapped"
: over
? "Not enough " + pay
: "Swap"
const cardClass =
"rounded-3xl border border-neutral-200 bg-white p-5 transition-colors duration-200 dark:border-neutral-800 dark:bg-neutral-950"
const mutedClass =
"text-neutral-500 dark:text-neutral-400"
const primaryTextClass =
"text-neutral-950 dark:text-neutral-50"
return (
<LayoutGroup>
<div className={cn("w-full max-w-sm", className)}>
<div className="relative space-y-1">
<section className={cardClass}>
<p className={cn("text-sm", mutedClass)}>
Pay
</p>
<div className="mt-2 flex items-center justify-between gap-3">
<input
aria-label="Amount to pay"
inputMode="decimal"
value={amount}
onChange={(e) => {
const next = e.target.value
if (/^\d*\.?\d*$/.test(next)) {
setAmount(next)
}
}}
className={cn(
"w-full min-w-0 bg-transparent text-4xl font-semibold tabular-nums tracking-tight outline-none placeholder:text-neutral-300 dark:placeholder:text-neutral-700",
primaryTextClass
)}
placeholder="0"
/>
<Chip
symbol={pay}
onPick={pickPay}
/>
</div>
<div
className={cn(
"mt-3 flex items-center justify-between gap-2 text-sm",
mutedClass
)}
>
<span className="tabular-nums">
{usd(value)}
</span>
<span className="flex items-center gap-2 whitespace-nowrap">
You have{" "}
<span className="tabular-nums text-neutral-700 dark:text-neutral-300">
{fmt(balances[pay], 6)}
</span>
<button
type="button"
onClick={() =>
setAmount(String(balances[pay]))
}
className="rounded-md bg-sky-500/10 px-1.5 py-0.5 text-xs font-medium text-sky-700 transition-colors hover:bg-sky-500/20 dark:text-sky-400"
>
MAX
</button>
</span>
</div>
</section>
<motion.button
type="button"
aria-label="Swap pay and receive"
onClick={flip}
animate={{ rotate: turn * 180 }}
whileTap={{ scale: 0.9 }}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
className="absolute left-1/2 top-1/2 z-10 grid size-11 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border-4 border-white bg-white text-neutral-700 shadow-sm transition-colors dark:border-neutral-950 dark:bg-neutral-900 dark:text-neutral-200"
>
<ArrowDownUp className="size-4" />
</motion.button>
<section className={cardClass}>
<p className={cn("text-sm", mutedClass)}>
Receive
</p>
<div className="mt-2 flex items-center justify-between gap-3">
<div className="min-w-0 overflow-hidden">
<AnimatePresence
mode="popLayout"
initial={false}
>
<motion.p
key={`${rec}-${fmt(receive)}`}
initial={{
y: 18,
opacity: 0,
filter: "blur(4px)",
}}
animate={{
y: 0,
opacity: 1,
filter: "blur(0px)",
}}
exit={{
y: -18,
opacity: 0,
filter: "blur(4px)",
}}
transition={{ duration: 0.25 }}
className={cn(
"truncate text-4xl font-semibold tabular-nums tracking-tight",
primaryTextClass
)}
>
{receive ? "~" + fmt(receive) : "0"}
</motion.p>
</AnimatePresence>
</div>
<Chip
symbol={rec}
onPick={pickRec}
/>
</div>
<div
className={cn(
"mt-3 flex items-center justify-between gap-2 text-sm",
mutedClass
)}
>
<span className="tabular-nums">
{usd(value)}
</span>
<span className="whitespace-nowrap">
You have{" "}
<span className="tabular-nums text-neutral-700 dark:text-neutral-300">
{balances[rec]
? fmt(balances[rec], 6)
: "none"}
</span>
</span>
</div>
</section>
</div>
<p
className={cn(
"px-2 py-3 text-xs tabular-nums",
mutedClass
)}
>
1 {pay} = {fmt(rate, 5)} {rec}
</p>
<motion.button
type="button"
onClick={swap}
disabled={
!n ||
over ||
status !== "idle" ||
pay === rec
}
whileTap={{ scale: 0.98 }}
className="relative h-14 w-full overflow-hidden rounded-full bg-gradient-to-b from-sky-400 to-blue-600 text-lg font-medium text-white shadow-[0_8px_24px_-8px_rgba(37,99,235,0.6)] transition-opacity disabled:cursor-not-allowed disabled:opacity-60"
>
<motion.span
aria-hidden="true"
className="absolute inset-y-0 -left-1/2 w-1/2 bg-gradient-to-r from-transparent via-white/40 to-transparent"
animate={{
x:
status === "loading"
? ["0%", "400%"]
: "0%",
}}
transition={
status === "loading"
? {
duration: 0.9,
repeat: Infinity,
ease: "linear",
}
: { duration: 0 }
}
/>
<AnimatePresence
mode="wait"
initial={false}
>
<motion.span
key={label}
initial={{ y: 14, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -14, opacity: 0 }}
transition={{ duration: 0.18 }}
className="relative flex items-center justify-center gap-2"
>
{status === "done" && (
<Check className="size-5" />
)}
{label}
</motion.span>
</AnimatePresence>
</motion.button>
</div>
</LayoutGroup>
)
}
components/ui/coin-icon.tsx
import { cn } from "@/lib/utils"
export const COIN_META = {
BTC: { name: "Bitcoin", color: "#F7931A" },
ETH: { name: "Ethereum", color: "#627EEA" },
SOL: { name: "Solana", color: "#0B0B0F" },
USDT: { name: "Tether", color: "#26A17B" },
} as const
export type CoinSymbol = keyof typeof COIN_META
export function CoinIcon({
symbol,
className,
}: {
symbol: CoinSymbol
className?: string
}) {
return (
<svg
viewBox="0 0 32 32"
role="img"
aria-label={COIN_META[symbol].name}
className={cn("size-6 shrink-0", className)}
>
<defs>
<linearGradient id="coin-sol" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stopColor="#9945FF" />
<stop offset="1" stopColor="#14F195" />
</linearGradient>
</defs>
<circle cx="16" cy="16" r="16" fill={COIN_META[symbol].color} />
{symbol === "BTC" && (
<text
x="16"
y="22.5"
textAnchor="middle"
fontSize="19"
fontWeight="700"
fill="#fff"
>
₿
</text>
)}
{symbol === "ETH" && (
<g fill="#fff">
<path d="M16 5.5 9.8 16.2 16 19.8l6.2-3.6z" opacity=".95" />
<path d="m16 21.2-6.2-3.6L16 26.5l6.2-8.9z" opacity=".8" />
</g>
)}
{symbol === "SOL" && (
<g fill="url(#coin-sol)">
<path d="M11 10h13l-2.500 3H8.500z" />
<path d="M8.500 14.500h13l2.500 3H11z" />
<path d="M11 19h13l-2.500 3H8.500z" />
</g>
)}
{symbol === "USDT" && (
<path
fill="#fff"
d="M9 9h14v3h-5.500v2.200c3.300.2 5.500.8 5.500 1.600s-2.200 1.400-5.500 1.600V24h-3v-6.600c-3.300-.2-5.500-.8-5.500-1.600s2.200-1.400 5.500-1.600V12H9z"
/>
)}
</svg>
)
}