Skip to content

SwapWidget

From/to with the flip. The signature component.
Implemented
Status
Implemented
Family
4 of 13
Registry
@lopifi/swap-widget
npx shadcn@latest add @lopifi/theme @lopifi/swap-widget

The component, rendering

Real data, the shipped component. Switch the theme to check the claims the spec makes below.

Preview

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-secondary until 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.

components/ledger/swap-widget.tsx
"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

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.

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.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.