Crypto Swap

Signature workflows

USDT→ETH swap with slippage, route hops, expanding fee breakdown, approval-then-swap flow and an on-chain confirmation timeline.

You pay

≈ $1,000

You receive
0.29289

≈ $999.5

Price impact
0.05%
Min received (0.5% slippage)
0.29142 ETH
Slippage
Route
USDTUSDCWETHETH

One-time token approval, then the swap transaction.

Install

npx shadcn@latest add https://labs.duku.design/r/crypto-swap.json

Ask your agent to use this

Connect DUKU Labs to MCP, then hand your coding agent this prompt:

Use the DUKU Labs MCP server to install the Crypto Swap component (get_component "crypto-swap", then its install command). Adapt it to my existing design tokens, preserve all accessibility and reduced-motion behavior, and keep every interaction state working.

Interaction

TryWhat happens
Click the flip buttonPay and receive cards physically exchange position (power3.inOut), then values swap
Expand the quote rowPrice impact, min received, slippage presets and the route hops unfold (height tween)
Swap an ERC-20 the first timeApprove transaction runs first; the swap button takes over after
Confirm the swapForm morphs into a Submitted → Pending → Confirmed timeline with check-draws
Enter more than the balanceDestructive border + Insufficient balance, button disabled

Props

PropTypeDefaultDescription
tokensSwapToken[]DEFAULT_TOKENSSymbols, USD prices, balances, approval needs.
onSwapped(detail) => voidAfter simulated confirmation.
Source — registry/default/crypto/crypto-swap.tsx
"use client";

import * as React from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/lib/use-reduced-motion";
import { Button } from "@/registry/default/ui/button";
import { Spinner } from "@/registry/default/ui/spinner";

/* ------------------------------------------------------------------ */
/* Model                                                                */
/* ------------------------------------------------------------------ */

export interface SwapToken {
  symbol: string;
  name: string;
  /** USD price used for quoting. */
  usd: number;
  balance: number;
  /** Tailwind color class for the coin dot. */
  tone: string;
  /** ERC-20s need an approval transaction before the first swap. */
  needsApproval?: boolean;
}

export const DEFAULT_TOKENS: SwapToken[] = [
  { symbol: "USDT", name: "Tether USD", usd: 1, balance: 2450.75, tone: "bg-emerald-500", needsApproval: true },
  { symbol: "ETH", name: "Ether", usd: 3412.6, balance: 0.42, tone: "bg-indigo-500" },
  { symbol: "USDC", name: "USD Coin", usd: 1, balance: 830.1, tone: "bg-sky-500", needsApproval: true },
  { symbol: "WBTC", name: "Wrapped BTC", usd: 97540, balance: 0.012, tone: "bg-amber-500", needsApproval: true },
];

export interface SwapQuote {
  rate: number;
  priceImpact: number;
  gasUsd: number;
  route: string[];
}

export interface CryptoSwapProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSubmit"> {
  tokens?: SwapToken[];
  /** Called after the simulated on-chain confirmation. */
  onSwapped?: (detail: {
    from: string;
    to: string;
    amountIn: number;
    amountOut: number;
  }) => void;
  ref?: React.Ref<HTMLDivElement>;
}

const fmt = (n: number, max = 6) =>
  new Intl.NumberFormat("en-US", { maximumFractionDigits: max }).format(n);

function quote(from: SwapToken, to: SwapToken, amountIn: number): SwapQuote {
  const rate = from.usd / to.usd;
  const notional = amountIn * from.usd;
  const priceImpact = Math.min(9.9, (notional / 2_000_000) * 100);
  const direct =
    (from.symbol === "USDC" && to.symbol === "ETH") ||
    (from.symbol === "ETH" && to.symbol === "USDC");
  const route = direct
    ? [from.symbol, to.symbol]
    : from.symbol === "ETH" || to.symbol === "ETH"
      ? [from.symbol, "USDC", to.symbol === "ETH" ? "WETH" : to.symbol, ...(to.symbol === "ETH" ? ["ETH"] : [])]
      : [from.symbol, "USDC", to.symbol];
  return { rate, priceImpact, gasUsd: 2.6 + priceImpact * 0.4, route: [...new Set(route)] };
}

/* ------------------------------------------------------------------ */
/* Token select                                                         */
/* ------------------------------------------------------------------ */

function TokenSelect({
  tokens,
  value,
  exclude,
  onChange,
  label,
}: {
  tokens: SwapToken[];
  value: SwapToken;
  exclude: string;
  onChange: (t: SwapToken) => void;
  label: string;
}) {
  return (
    <div className="relative inline-flex items-center gap-1.5">
      <span aria-hidden="true" className={cn("size-4 rounded-full", value.tone)} />
      <select
        aria-label={label}
        value={value.symbol}
        onChange={(e) => {
          const t = tokens.find((x) => x.symbol === e.target.value);
          if (t) onChange(t);
        }}
        className="appearance-none rounded-md bg-transparent pr-4 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
      >
        {tokens
          .filter((t) => t.symbol !== exclude)
          .map((t) => (
            <option key={t.symbol} value={t.symbol}>
              {t.symbol}
            </option>
          ))}
      </select>
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth={2}
        className="pointer-events-none absolute right-0 size-3 text-muted-foreground"
        aria-hidden="true"
      >
        <path d="m6 9 6 6 6-6" />
      </svg>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Swap                                                                 */
/* ------------------------------------------------------------------ */

type Stage = "form" | "approving" | "swapping" | "done";

export function CryptoSwap({
  tokens = DEFAULT_TOKENS,
  onSwapped,
  className,
  ref,
  ...rest
}: CryptoSwapProps) {
  const reduced = useReducedMotion();
  const rootRef = React.useRef<HTMLDivElement>(null);
  React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);

  const [from, setFrom] = React.useState(tokens[0]);
  const [to, setTo] = React.useState(tokens[1]);
  const [amount, setAmount] = React.useState("1000");
  const [slippage, setSlippage] = React.useState(0.5);
  const [feesOpen, setFeesOpen] = React.useState(false);
  const [approved, setApproved] = React.useState<Record<string, boolean>>({});
  const [stage, setStage] = React.useState<Stage>("form");
  const [txStep, setTxStep] = React.useState(0);

  const amountIn = parseFloat(amount) || 0;
  const q = quote(from, to, amountIn);
  const amountOut = amountIn * q.rate * (1 - q.priceImpact / 100);
  const minReceived = amountOut * (1 - slippage / 100);
  const insufficient = amountIn > from.balance;
  const needsApproval = !!from.needsApproval && !approved[from.symbol];

  const feesRef = React.useRef<HTMLDivElement>(null);
  const payRef = React.useRef<HTMLDivElement>(null);
  const recvRef = React.useRef<HTMLDivElement>(null);

  /* Fee breakdown expands fluidly. */
  useGSAP(
    () => {
      if (!feesRef.current) return;
      if (reduced) {
        gsap.set(feesRef.current, { height: feesOpen ? "auto" : 0, opacity: feesOpen ? 1 : 0 });
        return;
      }
      gsap.to(feesRef.current, {
        height: feesOpen ? "auto" : 0,
        opacity: feesOpen ? 1 : 0,
        duration: 0.35,
        ease: "power3.out",
      });
    },
    { dependencies: [feesOpen, reduced] }
  );

  /* Tokens physically exchange position on flip. */
  const flip = () => {
    const doSwap = () => {
      setFrom(to);
      setTo(from);
      setAmount(fmt(amountOut, to.usd > 100 ? 5 : 2).replace(/,/g, ""));
    };
    if (reduced || !payRef.current || !recvRef.current) {
      doSwap();
      return;
    }
    const dy = recvRef.current.offsetTop - payRef.current.offsetTop;
    const tl = gsap.timeline({
      onComplete: () => {
        gsap.set([payRef.current, recvRef.current], { y: 0 });
        doSwap();
      },
    });
    tl.to(payRef.current, { y: dy, duration: 0.35, ease: "power3.inOut" }, 0)
      .to(recvRef.current, { y: -dy, duration: 0.35, ease: "power3.inOut" }, 0);
  };

  const approve = async () => {
    setStage("approving");
    await new Promise((r) => setTimeout(r, 1400));
    setApproved((a) => ({ ...a, [from.symbol]: true }));
    setStage("form");
  };

  const swap = async () => {
    setStage("swapping");
    setTxStep(0);
    await new Promise((r) => setTimeout(r, 900));
    setTxStep(1);
    await new Promise((r) => setTimeout(r, 1400));
    setTxStep(2);
    setStage("done");
    onSwapped?.({ from: from.symbol, to: to.symbol, amountIn, amountOut });
  };

  const reset = () => {
    setStage("form");
    setTxStep(0);
    setAmount("");
  };

  const tokenCard = (
    which: "pay" | "receive",
    token: SwapToken,
    onToken: (t: SwapToken) => void
  ) => (
    <div
      ref={which === "pay" ? payRef : recvRef}
      className={cn(
        "rounded-xl border bg-background p-3",
        which === "pay" && insufficient ? "border-destructive" : "border-border"
      )}
    >
      <div className="flex items-center justify-between text-[11px] text-muted-foreground">
        <span>{which === "pay" ? "You pay" : "You receive"}</span>
        <button
          type="button"
          onClick={which === "pay" ? () => setAmount(String(token.balance)) : undefined}
          disabled={which !== "pay"}
          className={cn(
            "tabular-nums",
            which === "pay" &&
              "underline-offset-2 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          )}
        >
          Bal {fmt(token.balance, 4)}
          {which === "pay" ? " · MAX" : ""}
        </button>
      </div>
      <div className="mt-1.5 flex items-center justify-between gap-2">
        {which === "pay" ? (
          <input
            inputMode="decimal"
            aria-label="Amount to pay"
            value={amount}
            onChange={(e) => setAmount(e.target.value.replace(/[^0-9.]/g, ""))}
            placeholder="0.0"
            className="w-full min-w-0 bg-transparent text-2xl font-semibold tabular-nums text-foreground placeholder:text-muted-foreground/50 focus-visible:outline-none"
          />
        ) : (
          <span className="truncate text-2xl font-semibold tabular-nums text-foreground">
            {amountIn > 0 ? fmt(amountOut, token.usd > 100 ? 5 : 2) : "0.0"}
          </span>
        )}
        <TokenSelect
          tokens={tokens}
          value={token}
          exclude={which === "pay" ? to.symbol : from.symbol}
          onChange={onToken}
          label={which === "pay" ? "Pay token" : "Receive token"}
        />
      </div>
      <p className="mt-1 text-[11px] tabular-nums text-muted-foreground">
        ≈ ${fmt((which === "pay" ? amountIn : amountOut) * token.usd, 2)}
        {which === "pay" && insufficient ? (
          <span className="ml-2 font-medium text-destructive">
            Insufficient balance
          </span>
        ) : null}
      </p>
    </div>
  );

  return (
    <div
      ref={rootRef}
      data-slot="crypto-swap"
      className={cn(
        "w-full max-w-sm rounded-2xl border border-border bg-card p-4",
        className
      )}
      {...rest}
    >
      {stage !== "done" ? (
        <>
          <div className="relative flex flex-col gap-2">
            {tokenCard("pay", from, setFrom)}
            <button
              type="button"
              aria-label="Flip tokens"
              onClick={flip}
              className="absolute left-1/2 top-1/2 z-10 flex size-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-lg border border-border bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:text-foreground active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" className="size-4" aria-hidden="true">
                <path d="M7 4v16m0 0-3-3m3 3 3-3M17 20V4m0 0-3 3m3-3 3 3" />
              </svg>
            </button>
            {tokenCard("receive", to, setTo)}
          </div>

          {/* Quote summary */}
          <button
            type="button"
            aria-expanded={feesOpen}
            onClick={() => setFeesOpen((o) => !o)}
            className="mt-3 flex w-full items-center justify-between rounded-lg px-1 py-1 text-xs text-muted-foreground transition-colors duration-200 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <span className="tabular-nums">
              1 {from.symbol} = {fmt(q.rate, q.rate < 0.01 ? 6 : 4)} {to.symbol}
            </span>
            <span className="flex items-center gap-1 tabular-nums">
              ${q.gasUsd.toFixed(2)} gas
              <svg
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                className={cn("size-3 transition-transform duration-300", feesOpen && "rotate-180")}
                aria-hidden="true"
              >
                <path d="m6 9 6 6 6-6" />
              </svg>
            </span>
          </button>
          <div ref={feesRef} className="overflow-hidden" style={{ height: 0, opacity: 0 }}>
            <dl className="flex flex-col gap-1.5 px-1 pb-1 pt-2 text-xs">
              <div className="flex justify-between">
                <dt className="text-muted-foreground">Price impact</dt>
                <dd className={cn("tabular-nums", q.priceImpact > 2 ? "text-warning" : "text-foreground")}>
                  {q.priceImpact.toFixed(2)}%
                </dd>
              </div>
              <div className="flex justify-between">
                <dt className="text-muted-foreground">Min received ({slippage}% slippage)</dt>
                <dd className="tabular-nums text-foreground">
                  {fmt(minReceived, 5)} {to.symbol}
                </dd>
              </div>
              <div className="flex items-center justify-between">
                <dt className="text-muted-foreground">Slippage</dt>
                <dd className="flex gap-1">
                  {[0.1, 0.5, 1].map((s) => (
                    <button
                      key={s}
                      type="button"
                      aria-pressed={slippage === s}
                      onClick={() => setSlippage(s)}
                      className={cn(
                        "rounded px-1.5 py-0.5 tabular-nums transition-colors duration-150",
                        slippage === s
                          ? "bg-primary text-primary-foreground"
                          : "bg-muted text-muted-foreground hover:text-foreground",
                        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                      )}
                    >
                      {s}%
                    </button>
                  ))}
                </dd>
              </div>
              <div className="flex justify-between">
                <dt className="text-muted-foreground">Route</dt>
                <dd className="flex items-center gap-1">
                  {q.route.map((hop, i) => (
                    <React.Fragment key={hop}>
                      {i > 0 ? (
                        <span aria-hidden="true" className="text-muted-foreground">→</span>
                      ) : null}
                      <span className="rounded bg-muted px-1 py-0.5 text-[10px] font-medium text-foreground">
                        {hop}
                      </span>
                    </React.Fragment>
                  ))}
                </dd>
              </div>
            </dl>
          </div>

          <div className="mt-3">
            {needsApproval && amountIn > 0 && !insufficient ? (
              <Button
                className="w-full"
                loading={stage === "approving"}
                onClick={approve}
              >
                Approve {from.symbol}
              </Button>
            ) : (
              <Button
                className="w-full"
                disabled={amountIn <= 0 || insufficient}
                loading={stage === "swapping"}
                onClick={swap}
              >
                {insufficient ? "Insufficient balance" : "Swap"}
              </Button>
            )}
            {needsApproval && amountIn > 0 && !insufficient ? (
              <p className="mt-1.5 text-center text-[11px] text-muted-foreground">
                One-time token approval, then the swap transaction.
              </p>
            ) : null}
          </div>
        </>
      ) : (
        <TxTimeline
          from={from}
          to={to}
          amountIn={amountIn}
          amountOut={amountOut}
          step={txStep}
          onReset={reset}
        />
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Confirmation timeline                                                */
/* ------------------------------------------------------------------ */

const TX_STEPS = ["Submitted", "Pending on-chain", "Confirmed"] as const;

function TxTimeline({
  from,
  to,
  amountIn,
  amountOut,
  step,
  onReset,
}: {
  from: SwapToken;
  to: SwapToken;
  amountIn: number;
  amountOut: number;
  step: number;
  onReset: () => void;
}) {
  const ref = React.useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();

  useGSAP(
    () => {
      if (!ref.current) return;
      if (reduced) {
        gsap.set(ref.current, { opacity: 1, y: 0 });
        return;
      }
      gsap.fromTo(
        ref.current,
        { opacity: 0, y: 14, filter: "blur(8px)" },
        { opacity: 1, y: 0, filter: "blur(0px)", duration: 0.45, ease: "power3.out", clearProps: "filter" }
      );
    },
    { scope: ref }
  );

  return (
    <div ref={ref} className="flex flex-col gap-4 py-2">
      <div className="text-center">
        <p className="text-sm font-semibold text-foreground">
          {fmt(amountIn)} {from.symbol} → {fmt(amountOut, 5)} {to.symbol}
        </p>
        <p className="mt-0.5 text-[11px] text-muted-foreground">
          Swap complete · tx 0x3f8a…c21d
        </p>
      </div>
      <ol className="flex flex-col gap-0">
        {TX_STEPS.map((label, i) => {
          const done = step > i || (i === TX_STEPS.length - 1 && step >= i);
          const active = step === i && i < TX_STEPS.length - 1;
          return (
            <li key={label} className="flex gap-3">
              <div className="flex flex-col items-center">
                <span
                  className={cn(
                    "flex size-5 items-center justify-center rounded-full text-[10px]",
                    done
                      ? "bg-success text-white"
                      : active
                        ? "bg-primary/15"
                        : "bg-muted"
                  )}
                >
                  {done ? (
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" className="size-3" aria-hidden="true">
                      <path d="M5 13l4 4L19 7" />
                    </svg>
                  ) : active ? (
                    <Spinner size="sm" />
                  ) : null}
                </span>
                {i < TX_STEPS.length - 1 ? (
                  <span className={cn("my-0.5 h-5 w-px", done ? "bg-success" : "bg-border")} />
                ) : null}
              </div>
              <span
                className={cn(
                  "pt-0.5 text-sm",
                  done ? "text-foreground" : "text-muted-foreground"
                )}
              >
                {label}
              </span>
            </li>
          );
        })}
      </ol>
      <Button variant="outline" onClick={onReset}>
        New swap
      </Button>
    </div>
  );
}