SwapWidget
- Status
- Implemented
- Family
- 4 of 13
- Registry
- @lopifi/swap-widget
npx shadcn@latest add @lopifi/theme @lopifi/swap-widgetThe component, rendering
Real data, the shipped component. Switch the theme to check the claims the spec makes below.
The four-layer spec
The signature component. From/to with the flip.
┌─ 24 ──────────────────────────────────┐
│ You send │ label
│ [1,000.00] [USD ▾] │ figure-lg · currency select
│ Balance $4,120.00 [Max] │ caption
│ │
│ ⟨ ⇅ ⟩ ← flip button │ centered, overlapping the divider
│ │
│ Recipient gets │
│ [918.42] [EUR ▾] │
│ │
│ 1 USD = 0.91842 EUR · 12s [locked] │ RateLine
│ ├─ ConversionBreakdown ─────────────┤ │
└────────────────────────────────────────┘
The flip
The one place spring.playful is used.
icon rotateY? no — rotate(180deg), spring.playful { duration: 0.45, bounce: 0.22 }
fields cross-translate: top ↕ bottom, transform only, same spring
values swap INSTANTLY. The amounts do not interpolate. (Money Law 1)
The fields move; the numbers don't animate. That distinction is the whole doctrine in one component — motion explains what happened structurally, and never renders a false value.
Under reduced motion: instant swap, no rotation.
Rules
- Editing either field recomputes the other, with a 140ms blur-masked crossfade on the computed value — never a count-up.
- The computed field is visibly derived —
--text-secondaryuntil it's the focused one. - Debounce rate fetches at 300ms; never show a stale computed value as current. During refetch, dim the computed value to 0.6.
- Insufficient balance is an inline error under the send field, not a disabled button.
- Fee and rate are always visible without expanding anything.
Lifted verbatim from Swaps & FX. The doc and this page are the same paragraphs, so they cannot disagree.
What lands in your repository
One component is never one file. Everything below is copied into your project, where you own it.
- Files
components/ledger/swap-widget.tsx
- Lopifi dependencies · 17
- as-ofbuttoncnconversion-breakdowncurrency-badgecurrency-selectfiscal-periodformat-moneyiconsmoneymoney.typesrate-lineruletooltipuse-live-valueuse-media-queryuse-shared-now
- npm packages
- @base-ui-components/reactclass-variance-authorityclsxtailwind-merge
- Registry entry
- /r/swap-widget.json
The whole component
No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.
"use client";
import * as React from "react";
import { useReducedMotion } from "@/hooks/ledger/use-media-query";
import { cn } from "@/lib/ledger/cn";
import { currencySymbol, formatMoneyInput, parseMoneyInput } from "@/lib/ledger/format-money";
import type { MoneyValue } from "@/lib/ledger/money.types";
import { Button } from "@/components/ledger/button";
import { ConversionBreakdown } from "@/components/ledger/conversion-breakdown";
import { CurrencySelect } from "@/components/ledger/currency-select";
import { ArrowsUpDownIcon } from "@/components/ledger/icons";
import { Money } from "@/components/ledger/money";
import { RateLine } from "@/components/ledger/rate-line";
export interface SwapWidgetProps {
balance: MoneyValue;
className?: string;
/**
* When the rates were quoted. Supplied by the caller, never invented here —
* a rate the component timestamps itself is a rate nobody can verify, and
* a `new Date()` at render time also differs between server and client.
*/
rateAsOf: Date;
rates: Record<string, number>;
}
/**
* The signature component. From/to with the flip.
*
* The fields move; the numbers don't animate. That distinction is the whole
* doctrine in one component — motion explains what happened structurally,
* and never renders a false value.
*/
export function SwapWidget({ balance, rates, rateAsOf, className }: SwapWidgetProps) {
const [from, setFrom] = React.useState("USD");
const [to, setTo] = React.useState("EUR");
const [sendMinor, setSendMinor] = React.useState<number | null>(100_000);
const [editing, setEditing] = React.useState<"send" | "receive">("send");
const [flipping, setFlipping] = React.useState(false);
/* The arrow keeps turning the same way on every swap. Rotating to 180° and
back would read as the swap being undone. */
const [turns, setTurns] = React.useState(0);
const [refreshing, setRefreshing] = React.useState(false);
const reduced = useReducedMotion();
const rate = (rates[`${from}/${to}`] ?? 1 / (rates[`${to}/${from}`] ?? 1)) || 1;
const feeMinor = 450;
const convertibleMinor = (sendMinor ?? 0) - feeMinor;
const receiveMinor = Math.max(0, Math.round(convertibleMinor * rate));
const insufficient = sendMinor != null && sendMinor > Number(balance.amount);
const flip = () => {
// The amounts swap instantly. The fields cross-translate.
setTurns((t) => t + 1);
if (!reduced) {
setFlipping(true);
setTimeout(() => setFlipping(false), 180);
}
setFrom(to);
setTo(from);
setSendMinor(receiveMinor);
};
return (
<div className={cn("rounded-xl border border-border bg-surface p-6", className)}>
<div className="relative">
<Panel
balance={balance}
currency={from}
derived={false}
error={insufficient ? "That’s more than your available balance." : undefined}
label="You send"
minorUnits={sendMinor}
onCurrencyChange={setFrom}
onMax={() => setSendMinor(Number(balance.amount))}
onMinorUnitsChange={(v) => {
setEditing("send");
setSendMinor(v);
}}
translate={flipping ? 1 : 0}
/>
<div className="relative z-sticky flex justify-center py-2">
<button
aria-label={`Swap ${from} and ${to}`}
className={cn(
"grid size-10 place-items-center rounded-full border border-border bg-surface",
"text-text-secondary shadow-elevation-1",
"hover:bg-surface-hover",
)}
onClick={flip}
style={{
transform: `rotate(${turns * 180}deg)`,
/* No overshoot: a control that bounces beside two amounts of money
reads as the amounts being unsettled. */
transition: reduced ? "none" : "transform var(--duration-4) var(--ease-in-out)",
}}
type="button"
>
<ArrowsUpDownIcon aria-hidden="true" className="size-4" />
</button>
</div>
<Panel
currency={to}
derived={editing === "send"}
label="Recipient gets"
minorUnits={receiveMinor}
onCurrencyChange={setTo}
onMinorUnitsChange={() => setEditing("receive")}
refreshing={refreshing}
translate={flipping ? -1 : 0}
/>
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<RateLine asOf={rateAsOf} from={from} live rate={rate} to={to} />
<Button
onClick={() => {
setRefreshing(true);
setTimeout(() => setRefreshing(false), 300);
}}
size="sm"
variant="ghost"
>
Refresh
</Button>
</div>
<div className="mt-4 border-t border-border-subtle pt-4">
<ConversionBreakdown
amount={{ amount: sendMinor ?? 0, currency: from }}
converted={{ amount: convertibleMinor, currency: from }}
fees={[{ amount: { amount: feeMinor, currency: from }, label: "Our fee" }]}
from={from}
rate={rate}
rateAsOf={rateAsOf}
recipientGets={{ amount: receiveMinor, currency: to }}
to={to}
/>
</div>
</div>
);
}
function Panel({
label,
currency,
onCurrencyChange,
minorUnits,
onMinorUnitsChange,
derived,
refreshing,
balance,
onMax,
error,
translate,
}: {
label: string;
currency: string;
onCurrencyChange: (c: string) => void;
minorUnits: number | null;
onMinorUnitsChange: (v: number | null) => void;
derived: boolean;
refreshing?: boolean;
balance?: MoneyValue;
onMax?: () => void;
error?: string;
translate: number;
}) {
const [draft, setDraft] = React.useState("");
const [focused, setFocused] = React.useState(false);
const reduced = useReducedMotion();
React.useEffect(() => {
if (focused) return;
setDraft(minorUnits == null ? "" : formatMoneyInput(minorUnits, currency));
}, [minorUnits, currency, focused]);
return (
<div
className="rounded-md bg-surface-sunken p-4"
style={{
transform: `translateY(${translate * 6}px)`,
transition: reduced ? "none" : "transform var(--duration-3) var(--ease-in-out)",
}}
>
<span className="block text-label font-medium text-text-secondary">{label}</span>
<div className="mt-2 flex items-center gap-3">
<span aria-hidden="true" className="numeric text-figure-lg text-text-tertiary">
{currencySymbol(currency)}
</span>
<input
aria-label={`${label} amount in ${currency}`}
className={cn(
"numeric min-w-0 flex-1 bg-transparent text-figure-lg font-medium outline-none",
// The computed field is visibly derived.
derived && "text-text-secondary",
refreshing && "opacity-60",
)}
inputMode="decimal"
onBlur={() => {
setFocused(false);
const parsed = parseMoneyInput(draft, currency);
onMinorUnitsChange(parsed);
}}
onChange={(e) => {
setDraft(e.target.value);
onMinorUnitsChange(parseMoneyInput(e.target.value, currency));
}}
onFocus={() => setFocused(true)}
type="text"
value={draft}
/>
<CurrencySelect onValueChange={onCurrencyChange} recents={["USD", "EUR", "GBP"]} value={currency} />
</div>
<div className="mt-2 flex items-center justify-between gap-3">
{balance ? (
<span className="text-caption text-text-secondary">
Balance <Money size="xs" tone="inherit" value={balance} />
</span>
) : (
<span />
)}
{onMax ? (
<Button onClick={onMax} size="sm" variant="ghost">
Max
</Button>
) : null}
</div>
{error ? (
<p className="mt-1 text-caption text-negative" role="alert">
{error}
</p>
) : null}
</div>
);
}
Also in Swaps & FX
- RateLineA rate with its timestamp. Never one without the other.
- RateLockLocked rate with a countdown and expiry behavior.
- ConversionBreakdownAmount → fee → rate → recipient gets. Every line.
- CurrencyPairChartRate history with a period selector.
- RateTickerLive multi-pair ticker with value flash.
- SlippageControlTolerance setting with an explicit worst case.
- SwapRouteMulti-hop path visualization.
- FxExposurePosition by currency with unrealized gain/loss.
See also
A component is specified in one place, defined in another, and running in a third. All three, from here.
Elsewhere in the system
Where SwapWidget sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.
- SwapWidget, runningThe swaps showcase — live, with theme, density, greyscale and reduced-motion switches.Live
- Swaps & FXThe governing rule for this entire family: A rate is a perishable fact. It never appears without a timestamp, and it is never transacted on without a lock. Specs for 9 React components: SwapWidget, RateLine, RateLock, ConversionBreakdown and 5 more.Doc
Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.
Told when Swaps & FX changes
Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.