makeui
DocsComponents

© 2026 makeui. Open source, MIT licensed.

DocsComponentsGitHub

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.json

Dependencies

  • 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

PropTypeDescription
pricesPartial<Record<CoinSymbol, number>>USD price per coin. Defaults to sample prices, so pass live data.
balancesPartial<Record<CoinSymbol, number>>Starting balances for each coin.
defaultPayCoinSymbolCoin on the pay side. Defaults to USDT.
defaultReceiveCoinSymbolCoin on the receive side. Defaults to ETH.
onSwap(trade) => voidCalled after a swap with { from, to, pay, receive }.
classNamestringExtra 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>
  )
}

On this page

Pay

$164.23You have 4,537.5

Receive

~0.04977

$164.23You have none

1 USDT = 0.00030303 ETH