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
npm i framer-motion lucide-react
2. Files
"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>
);
}
.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;
}
/*
* 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
import { TokenSwap } from "./TokenSwap";
import "./TokenSwap.css";
export default function Demo() {
return <TokenSwap />;
}