Teseria
All elements

Token swap

Two cards, pay and receive. The arrow between them spins and the cards swap places on a spring, amounts rolling to the new rate (changed digits pop in from the right).

Code

1. Install

Terminal
npm i framer-motion lucide-react

2. Files

TokenSwap.tsx285 lines
"use client";
// TokenSwap: two cards, pay and receive. The arrow between them spins and the cards swap places on a spring, amounts rolling
// to the new rate (changed digits pop in from the right). Tap a coin and a small picker drops out of it; Escape and arrows work inside it.
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { ArrowDown, ChevronDown } from "lucide-react";

export type Coin = { sym: string; name: string; hue: number; usd: number };
export type TokenSwapCue = "unfold" | "fold" | "choose" | "flip";

export type TokenSwapQuote = { from: Coin; to: Coin; amount: number; receive: number };

export type TokenSwapProps = {
  /** The coins on offer; `usd` is the price in dollars, `hue` the oklch hue of its dot. */
  coins?: Coin[];
  /** Starting amount to pay. */
  defaultAmount?: number;
  /** Called whenever the pair or the amount changes, with the quote the cards show. */
  onChange?: (quote: TokenSwapQuote) => void;
  /** Sound hook: "unfold"/"fold" for the picker, "choose" on a choice, "flip" when the arrow reverses the pair. */
  onSound?: (cue: TokenSwapCue) => void;
  /** Label of the pay card. */
  payLabel?: string;
  /** Label of the receive card. */
  getLabel?: string;
  /** Accessible label of the amount field. */
  amountLabel?: string;
  /** Accessible label of the swap button. */
  swapLabel?: string;
  /** Width of the card in px. */
  width?: number;
  /** Corner radius of the card in px. */
  corner?: number;
  className?: string;
  style?: CSSProperties;
};

export const DEFAULT_COINS: Coin[] = [
  { sym: "ETH", name: "Ether", hue: 270, usd: 1984 },
  { sym: "USDC", name: "USD Coin", hue: 230, usd: 1 },
  { sym: "SOL", name: "Solana", hue: 320, usd: 142 },
  { sym: "BTC", name: "Bitcoin", hue: 60, usd: 61200 },
];

const DEFAULT_AMOUNT = 1.25;
const SNAP = { type: "spring", stiffness: 380, damping: 34, mass: 0.8 } as const;
const POP = { type: "spring", stiffness: 500, damping: 30, mass: 0.9 } as const;

type Cell = { key: string; ch: string; digit: boolean; fresh: boolean; i: number };

/** Compare from the right: a cell is "fresh" when the character at that position (from the end) changed. */
function split(next: string, prevText: string, gen: number, keys: number[]): Cell[] {
  const length = next.length;
  const prevLength = prevText.length;
  const out: Cell[] = [];
  let fromRight = 0;
  for (let i = length - 1; i >= 0; i--) {
    const ch = next[i];
    const prevIndex = i - (length - prevLength);
    const prevCh = prevIndex >= 0 ? prevText[prevIndex] : undefined;
    const j = length - 1 - i;
    const fresh = prevText !== "" && ch !== prevCh;
    if (fresh || keys[j] === undefined) keys[j] = gen;
    out.unshift({ ch, digit: /\d/.test(ch), fresh, i: fresh ? fromRight : 0, key: `${j}-${keys[j]}` });
    if (fresh) fromRight++;
  }
  keys.length = length;
  return out;
}

/** A number whose changed digits pop in one after another, from the right; unchanged digits stay put. */
function Flow({ value, digits, prefix = "", size = 24, stagger = 36 }: { value: number; digits: number; prefix?: string; size?: number; stagger?: number }) {
  const still = useReducedMotion();
  const text = new Intl.NumberFormat("en-US", { maximumFractionDigits: digits }).format(value);
  const prev = useRef(text);
  const keys = useRef<number[]>([]);
  const gen = useRef(0);
  const [cells, setCells] = useState<Cell[]>(() => split(text, "", 0, keys.current));
  useEffect(() => {
    if (prev.current === text) return;
    gen.current += 1;
    setCells(split(text, prev.current, gen.current, keys.current));
    prev.current = text;
  }, [text]);
  // When the digit count changes, glide the row's width instead of letting the number re-centre with a cut.
  const row = useRef<HTMLSpanElement>(null);
  const w = useRef(0);
  useLayoutEffect(() => {
    const el = row.current;
    if (!el) return;
    const from = el.style.width ? el.offsetWidth : w.current;
    el.style.width = "";
    const to = (w.current = el.offsetWidth);
    if (!from || still || Math.abs(from - to) < 1) return;
    el.style.width = `${from}px`;
    void el.offsetWidth; // commit the start width before transitioning
    el.style.width = `${to}px`;
    const done = (e: TransitionEvent) => {
      if (e.propertyName !== "width") return;
      el.style.width = "";
      el.removeEventListener("transitionend", done);
    };
    el.addEventListener("transitionend", done);
    return () => el.removeEventListener("transitionend", done);
  }, [cells, still]);
  return (
    <span className="tksw-nf" data-still={still || undefined} style={{ fontSize: size, fontWeight: 500, "--tksw-stagger": `${stagger}ms` } as CSSProperties}>
      <span className="tksw-nf-sr">{prefix}{text}</span>
      <span ref={row} className="tksw-nf-row" aria-hidden>
        {prefix && <span className="tksw-nf-aff">{prefix}</span>}
        {cells.map((c) => (
          <span key={c.key} className={c.digit ? "tksw-nf-cell" : "tksw-nf-cell tksw-nf-sep"} data-fresh={c.fresh || undefined} style={{ "--tksw-i": c.i } as CSSProperties}>
            {c.ch}
          </span>
        ))}
      </span>
    </span>
  );
}

export function TokenSwap({
  coins = DEFAULT_COINS,
  defaultAmount = DEFAULT_AMOUNT,
  onChange,
  onSound,
  payLabel = "You pay",
  getLabel = "You get",
  amountLabel = "Amount",
  swapLabel = "Swap direction",
  width = 290,
  corner = 24,
  className,
  style,
}: TokenSwapProps) {
  const uid = useId();
  const [fromSym, setFromSym] = useState(coins[0]?.sym);
  const [toSym, setToSym] = useState(coins[1]?.sym);
  const [amt, setAmt] = useState(defaultAmount);
  const [draft, setDraft] = useState(String(defaultAmount));
  const [spins, setSpins] = useState(0);
  const [pick, setPick] = useState<"from" | "to" | null>(null);
  const root = useRef<HTMLDivElement>(null);
  const back = useRef<"from" | "to" | null>(null);
  const from = coins.find((c) => c.sym === fromSym) ?? coins[0];
  const to = coins.find((c) => c.sym === toSym) ?? coins[1] ?? coins[0];
  const out = from && to ? (amt * from.usd) / to.usd : 0;
  const report = (pay: Coin, receive: Coin, amount: number) => onChange?.({ from: pay, to: receive, amount, receive: (amount * pay.usd) / receive.usd });

  useEffect(() => {
    if (!pick) return;
    const closeOnOutsidePress = (e: PointerEvent) => {
      if (!root.current?.querySelector(".tksw-pick")?.contains(e.target as Node)) setPick(null);
    };
    // listen from the next tick so the press that opened the picker does not close it
    const timer = setTimeout(() => document.addEventListener("pointerdown", closeOnOutsidePress), 0);
    return () => {
      clearTimeout(timer);
      document.removeEventListener("pointerdown", closeOnOutsidePress);
    };
  }, [pick]);
  // choosing a coin remounts its card; put focus back on the new coin button
  useEffect(() => {
    if (back.current) root.current?.querySelector<HTMLElement>(`.tksw-card[data-side="${back.current}"] .tksw-coin`)?.focus();
    back.current = null;
  }, [fromSym, toSym, pick]);
  if (!from || !to) return null;

  const flip = () => {
    onSound?.("flip");
    setSpins((s) => s + 1);
    setFromSym(to.sym);
    setToSym(from.sym);
    setAmt(+out.toFixed(4));
    setDraft(String(+out.toFixed(4)));
    report(to, from, +out.toFixed(4));
  };
  const choose = (c: Coin) => {
    onSound?.("choose");
    back.current = pick;
    const swapped = pick === "from" ? c.sym === to.sym : c.sym === from.sym;
    const pay = pick === "from" ? c : swapped ? to : from;
    const receive = pick === "from" ? (swapped ? from : to) : c;
    setFromSym(pay.sym);
    setToSym(receive.sym);
    setPick(null);
    report(pay, receive, amt);
  };

  const togglePicker = (side: "from" | "to") => {
    const closing = pick === side;
    onSound?.(closing ? "fold" : "unfold");
    setPick(closing ? null : side);
  };

  // picker keys: Escape closes back to the coin, arrows / Home / End walk the options
  const pickKeys = (e: KeyboardEvent<HTMLDivElement>, side: "from" | "to") => {
    if (pick !== side) return;
    const wrap = e.currentTarget;
    if (e.key === "Escape") {
      e.preventDefault();
      onSound?.("fold");
      setPick(null);
      wrap.querySelector<HTMLElement>(".tksw-coin")?.focus();
      return;
    }
    const opts = Array.from(wrap.querySelectorAll<HTMLElement>('[role="option"]'));
    const focused = opts.indexOf(document.activeElement as HTMLElement);
    const target = { ArrowDown: focused + 1, ArrowUp: focused < 0 ? -1 : focused - 1, Home: 0, End: -1 }[e.key];
    if (target === undefined || !opts.length) return;
    e.preventDefault();
    opts[(target + opts.length) % opts.length].focus();
  };

  const card = (side: "from" | "to") => {
    const c = side === "from" ? from : to;
    const v = side === "from" ? amt : out;
    return (
      <motion.div key={c.sym} layoutId={`${uid}-${c.sym}`} layout className="tksw-card" data-side={side} transition={SNAP} style={{ borderRadius: corner - 8 }}>
        <span className="tksw-k">{side === "from" ? payLabel : getLabel}</span>
        <div className="tksw-mid">
          {side === "from" ? (
            <input
              className="tksw-in"
              inputMode="decimal"
              value={draft}
              aria-label={amountLabel}
              onChange={(e) => {
                const t = e.target.value.replace(/[^0-9.]/g, "");
                setDraft(t);
                const n = parseFloat(t);
                if (!Number.isNaN(n)) {
                  setAmt(n);
                  report(from, to, n);
                }
              }}
              onBlur={() => setDraft(String(amt))}
            />
          ) : (
            <Flow value={+v.toFixed(v > 100 ? 2 : 4)} digits={v > 100 ? 2 : 4} size={24} />
          )}
          <div className="tksw-coinwrap" onKeyDown={(e) => pickKeys(e, side)}>
            <button type="button" className="tksw-coin" aria-haspopup="listbox" aria-expanded={pick === side} onClick={() => togglePicker(side)}>
              <i style={{ background: `oklch(0.7 0.16 ${c.hue})` }} />
              {c.sym}
              <ChevronDown size={13} strokeWidth={2.2} />
            </button>
            <AnimatePresence>
              {pick === side && (
                <motion.div key="p" role="listbox" className="tksw-pick" initial={{ opacity: 0, y: -8, scaleX: 0.86, scaleY: 0.72 }} animate={{ opacity: 1, y: 0, scaleX: 1, scaleY: 1 }} exit={{ opacity: 0, y: -6, scaleX: 0.92, scaleY: 0.86, transition: { duration: 0.12 } }} transition={{ ...POP, opacity: { duration: 0.14 } }}>
                  {coins.map((k, i) => (
                    <motion.button key={k.sym} type="button" role="option" aria-selected={k.sym === c.sym} data-on={k.sym === c.sym || undefined} initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} transition={{ ...SNAP, delay: 0.03 + i * 0.03, opacity: { duration: 0.12, delay: 0.03 + i * 0.03 } }} onClick={() => choose(k)}>
                      <i style={{ background: `oklch(0.7 0.16 ${k.hue})` }} />
                      <span>
                        {k.sym}
                        <small>{k.name}</small>
                      </span>
                    </motion.button>
                  ))}
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        </div>
        <span className="tksw-usd">
          ≈ <Flow value={+(v * c.usd).toFixed(2)} digits={2} prefix="$" size={12} stagger={20} />
        </span>
      </motion.div>
    );
  };

  return (
    <div ref={root} className={`tksw-root${className ? " " + className : ""}`} style={{ width, borderRadius: corner, ...style }}>
      <div className="tksw-stack">
        {card("from")}
        {card("to")}
        <motion.button type="button" className="tksw-flip" aria-label={swapLabel} onClick={flip} animate={{ rotate: spins * 180 }} transition={{ type: "spring", stiffness: 420, damping: 26, mass: 0.8 }}>
          <ArrowDown size={16} strokeWidth={2.4} />
        </motion.button>
      </div>
      <div className="tksw-rate">
        1 {from.sym} ≈ <Flow value={+(from.usd / to.usd).toFixed(4)} digits={4} size={12} /> {to.sym}
      </div>
    </div>
  );
}
TokenSwap.css238 lines
.tksw-root,
.tksw-root *,
.tksw-root *::before,
.tksw-root *::after {
  box-sizing: border-box;
}
.tksw-root {
  font-family: var(--ts-font, system-ui, sans-serif);
}
:where(.tksw-root) button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
}
:where(.tksw-root) :focus-visible {
  outline: 2px solid var(--ts-focus, #3446eb);
  outline-offset: 2px;
}
.tksw-root {
  padding: 8px;
  background: var(--ts-raised);
  color: var(--ts-fg);
  box-shadow: var(--ts-edge);
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.tksw-stack {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.tksw-card {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 12px 14px;
  background: rgba(var(--ts-fg-rgb), 0.05);
}
.tksw-k {
  font-size: 12px;
  opacity: 0.5;
}
.tksw-mid {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  height: 32px;
}
.tksw-in {
  width: 120px;
  padding: 0;
  border: 0;
  background: transparent;
  color: inherit;
  font: inherit;
  font-size: 24px;
  font-weight: 500;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
}
.tksw-coinwrap {
  position: relative;
}
.tksw-coin {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 30px;
  padding: 0 8px 0 6px;
  border-radius: 999px;
  background: rgba(var(--ts-fg-rgb), 0.08);
  color: inherit;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition: scale 0.22s cubic-bezier(.22, 1, .36, 1), background 0.16s cubic-bezier(.22, 1, .36, 1);
}
.tksw-coin:active {
  scale: 0.96;
}
.tksw-coin i,
.tksw-pick i {
  width: 18px;
  height: 18px;
  border-radius: 999px;
  flex: none;
}
.tksw-pick {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 5;
  width: 150px;
  padding: 5px;
  border-radius: var(--ts-r-pop);
  background: var(--ts-raised);
  box-shadow: var(--ts-edge), 0 12px 32px rgba(0, 0, 0, 0.18);
  transform-origin: 100% 0;
  display: flex;
  flex-direction: column;
}
.tksw-pick button {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 44px;
  padding: 0 8px;
  border-radius: calc(var(--ts-r-pop) - 5px);
  font-size: 13px;
  font-weight: 500;
  text-align: left;
  cursor: pointer;
  transition: background 0.16s cubic-bezier(.22, 1, .36, 1);
}
.tksw-pick button:hover,
.tksw-pick button[data-on] {
  background: rgba(var(--ts-fg-rgb), 0.06);
}
.tksw-pick span {
  display: flex;
  flex-direction: column;
  line-height: 1.1;
}
.tksw-pick small {
  font-size: 12px;
  font-weight: 400;
  opacity: 0.5;
}
.tksw-usd {
  font-size: 12px;
  opacity: 0.5;
  font-variant-numeric: tabular-nums;
}
.tksw-flip {
  position: absolute;
  left: 50%;
  top: 50%;
  z-index: 2;
  width: 34px;
  height: 34px;
  margin: -17px 0 0 -17px;
  border-radius: 999px;
  background: var(--ts-fg);
  color: var(--ts-raised);
  box-shadow: 0 0 0 3px var(--ts-raised);
  display: grid;
  place-items: center;
  cursor: pointer;
  transition: scale 0.22s cubic-bezier(.22, 1, .36, 1);
}
.tksw-flip:active {
  scale: 0.92;
}
.tksw-rate {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 0 8px 4px;
  font-size: 12px;
  opacity: 0.6;
}

/* odometer */
.tksw-nf {
  position: relative;
  display: inline-block;
  color: inherit;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.02em;
  line-height: 1;
  transition: color 0.22s cubic-bezier(.22, 1, .36, 1);
}
.tksw-nf-sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}
.tksw-nf-row {
  display: inline-flex;
  align-items: baseline;
  justify-content: flex-end; /* while the width glides, a new leading digit overflows to the left */
  white-space: pre;
  transition: width 0.42s cubic-bezier(.22, 1, .36, 1);
}
.tksw-nf-cell {
  display: inline-block;
}
.tksw-nf-sep {
  opacity: 0.55;
}
.tksw-nf-aff {
  opacity: 0.55;
  font-size: 0.7em;
  margin: 0 0.08em;
}
.tksw-nf-cell[data-fresh] {
  animation: tksw-nf-in 0.42s cubic-bezier(.24, 1.34, .38, 1) backwards;
  animation-delay: calc(var(--tksw-i) * var(--tksw-stagger, 36ms));
}
@keyframes tksw-nf-in {
  0% {
    opacity: 0;
    filter: blur(5px);
    scale: 0.6;
    translate: 0 0.18em;
  }
  to {
    opacity: 1;
    filter: none;
    scale: 1;
    translate: 0 0;
  }
}
.tksw-nf[data-still] .tksw-nf-cell[data-fresh] {
  animation: none;
}
@media (prefers-reduced-motion: reduce) {
  .tksw-nf-cell[data-fresh] {
    animation: none;
  }
}

/* defaults for the custom properties the component sets inline */
.tksw-card {
  --tksw-i: 0;
}
.tksw-nf {
  --tksw-stagger: 36ms;
}
tokens.cssshared · add once
/*
 * Teseria tokens — the only colours, fonts and corner radii a block reads.
 *
 * Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
 * the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
 * any ancestor, or the OS setting unless [data-theme="light"] is set.
 *
 *   Surfaces   page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
 *   Text       fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
 *   Lines      border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
 *              dark; composes with other shadows)
 *   Signals    accent · focus · success · warning · danger
 *   Corners    controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
 *              (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
 */
:root {
  --ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --ts-r-panel: 24px;
  --ts-r-pop: 18px;
  --ts-r-inner: 12px;

  --ts-page: #ffffff;
  --ts-raised: #ffffff;
  --ts-sunken: #f3f2f0;
  --ts-tint-1: #f5f4f2;
  --ts-tint-2: #ecebe8;
  --ts-tint-3: #e3e1dd;

  --ts-fg: #161514;
  --ts-fg-rgb: 22, 21, 20;
  --ts-fg-secondary: #3a3836;
  --ts-fg-tertiary: #585552;
  --ts-fg-muted: #6d6a66;
  --ts-fg-faint: #89857f;
  --ts-fg-inverse: #ffffff;

  --ts-border: rgba(22, 21, 20, 0.1);
  --ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);

  --ts-accent: #3446eb;
  --ts-focus: #3446eb;
  --ts-success: #13804a;
  --ts-warning: #a9650b;
  --ts-danger: #df474e;
}

:root[data-theme="dark"] {
  --ts-page: #0b0a09;
  --ts-raised: #24221f;
  --ts-sunken: #0d0c0b;
  --ts-tint-1: #131211;
  --ts-tint-2: #1b1a18;
  --ts-tint-3: #2b2926;

  --ts-fg: #f6f5f3;
  --ts-fg-rgb: 246, 245, 243;
  --ts-fg-secondary: #bab8b4;
  --ts-fg-tertiary: #9e9b97;
  --ts-fg-muted: #8e8b86;
  --ts-fg-faint: #72706c;
  --ts-fg-inverse: #0b0a09;

  --ts-border: rgba(246, 245, 243, 0.1);
  --ts-edge: 0 0 #0000;

  --ts-accent: #3446eb;
  --ts-focus: #7f8cff;
  --ts-success: #3ccf80;
  --ts-warning: #efa130;
  --ts-danger: #df474e;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ts-page: #0b0a09;
    --ts-raised: #24221f;
    --ts-sunken: #0d0c0b;
    --ts-tint-1: #131211;
    --ts-tint-2: #1b1a18;
    --ts-tint-3: #2b2926;

    --ts-fg: #f6f5f3;
    --ts-fg-rgb: 246, 245, 243;
    --ts-fg-secondary: #bab8b4;
    --ts-fg-tertiary: #9e9b97;
    --ts-fg-muted: #8e8b86;
    --ts-fg-faint: #72706c;
    --ts-fg-inverse: #0b0a09;

    --ts-border: rgba(246, 245, 243, 0.1);
    --ts-edge: 0 0 #0000;

    --ts-accent: #3446eb;
    --ts-focus: #7f8cff;
    --ts-success: #3ccf80;
    --ts-warning: #efa130;
    --ts-danger: #df474e;
  }
}

3. Usage

Demo.tsx
import { TokenSwap } from "./TokenSwap";
import "./TokenSwap.css";

export default function Demo() {
  return <TokenSwap />;
}