Swaps & FX
The 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.
38. SwapWidget
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.
39. RateLine
<RateLine from="USD" to="EUR" rate={0.91842} asOf={t} />
// 1 USD = 0.91842 EUR · 12s ago
5 significant figures, tnum. The timestamp is not optional — the component has no variant without it.
Live variant flashes on update (value flash), throttled to one flash per 400ms.
40. RateLock
A locked rate with a countdown. The countdown is a rule-line progress bar, linear, consistent with the statement grammar rather than a rounded progress pill.
On expiry: the rate refreshes, the new rate crossfades in, and the confirm action is disabled until the user acknowledges the new rate. Silently executing at a rate the user didn't see is the worst failure this family can have.
Warning state at 20% remaining — amber, plus a text countdown, never color alone.
41. ConversionBreakdown
Wise's honesty pattern, as a component. Every line visible.
Amount to convert 1,000.00 USD
Our fee −4.50 USD ← itemized, named
Amount we'll convert 995.50 USD
────────── ← single rule
Rate 1 USD = 0.91842 EUR · 09:12 UTC
Recipient gets 914.29 EUR
══════════ ← double rule
- Every fee named and itemized. Never a lump "Fees".
- The rate line carries its timestamp.
- Uses
Rule— the same grammar as the statements, which is what makes the library feel like one system. - Percentage-of-total splits use largest-remainder allocation, so the parts sum to the total exactly.
42. CurrencyPairChart
Rate history with a period selector. Y-axis does not start at zero — it's a rate, not a quantity, and forcing zero destroys the resolution. This is the one sanctioned exception to the zero-baseline rule, and the axis is labeled clearly so it can't mislead.
Draws once on mount. Instant on period change.
43. RateTicker
Live multi-pair ticker. The value flash is the whole point. Pauses off-screen and on tab hide. Never a marquee — a scrolling ticker is continuous large-surface motion that can't be read and can't be paused.
44. SlippageControl
Tolerance setting that always states the worst case in absolute money: "You'll receive at least 910.15 EUR". A percentage alone is not a decision-grade number.
45. SwapRoute
Multi-hop path (USD → USDC → EUR). Rendered as a horizontal flow, each hop with its own rate and fee. A rare, legitimate use for the isometric flow-diagram treatment — motion explains the mechanism.
46. FxExposure
Position by currency with unrealized gain/loss. Uses polarity="standard" per position, and clearly separates realized from unrealized — they're different facts and conflating them misstates the position.