Exchange

An amount in one currency and what it comes to in another, in two cards that swap places with one press.

'use client';

import { Exchange, type ExchangeCurrency } from '@/components/panelui/exchange';

const CURRENCIES: ExchangeCurrency[] = [
  { code: 'USD', name: 'US dollar', symbol: '$', color: 'chart-2' },
  { code: 'EUR', name: 'Euro', symbol: '€', color: 'chart-1' },
  { code: 'GBP', name: 'Pound sterling', symbol: '£', color: 'chart-4' },
  { code: 'JPY', name: 'Japanese yen', symbol: '¥', decimals: 0, color: 'chart-5' },
  { code: 'SAR', name: 'Saudi riyal', symbol: 'ر', color: 'chart-3' },
];

/** Units of each currency to one US dollar. Sample figures, not live rates. */
const RATES = { USD: 1, EUR: 0.866, GBP: 0.762, JPY: 153.2, SAR: 3.75 };

export default function ExchangeOverview() {
  return (
    <Exchange currencies={CURRENCIES} rates={RATES} defaultValue={{ from: 'USD', to: 'EUR', amount: 250 }}>
      <Exchange.Field side="from" label="You pay" />
      <Exchange.Swap />
      <Exchange.Field side="to" label="You receive" />
    </Exchange>
  );
}

Use it where someone converts an amount between two currencies: sending money abroad, ordering travel money, or showing what a price comes to at home. Each side is a card with its label, the amount, the currency and the rate. The round button between them swaps the two cards, figures and all.

It fetches no rates. Pass rates, how many units of each currency one unit of a common base is worth, or convert for a rule of your own, such as a fee. The amount on the other side is rounded to that currency's minor unit.

Either card can be typed in. The one typed in holds its figure and the other follows, which is how someone asks for an exact amount to arrive.

Installation

npx shadcn@latest add https://panelui.dev/r/web/exchange.json

The source is copied into components/panelui/, where you can edit it.

Usage

import { Exchange } from "@/components/panelui/exchange"
<Exchange currencies={currencies} rates={rates} defaultValue={{ from: 'USD', to: 'EUR', amount: 100 }}>
  <Exchange.Field side="from" label="You pay" />
  <Exchange.Swap />
  <Exchange.Field side="to" label="You receive" />
</Exchange>

Composition

<Exchange>
  <Exchange.Field side="from" />
  <Exchange.Swap />
  <Exchange.Field side="to" />
</Exchange>
  • Exchange.Field — One card: its label, the amount in large figures, the currency's pill and a quieter line under them. The line is the rate to the other currency unless detail says otherwise, and detail={null} removes it. Pressing the pill opens the currency list over both cards.
  • Exchange.Swap — The round button on the seam. It swaps the two cards' places, so the figure each one showed moves with it, and its arrow turns half a turn each time.

Examples

Held by you

Pass value and onValueChange to hold the currencies and the amount yourself. side says which card the amount belongs to: here to, so what arrives is fixed at 500 rupees and what is sent follows from the rate.

'use client';

import { useState } from 'react';
import { Exchange, type ExchangeCurrency, type ExchangeValue } from '@/components/panelui/exchange';

const CURRENCIES: ExchangeCurrency[] = [
  { code: 'GBP', name: 'Pound sterling', symbol: '£', color: 'chart-4' },
  { code: 'EUR', name: 'Euro', symbol: '€', color: 'chart-1' },
  { code: 'AED', name: 'UAE dirham', symbol: 'د', color: 'chart-3' },
  { code: 'INR', name: 'Indian rupee', symbol: '₹', color: 'chart-5' },
];

/** Units of each currency to one pound. Sample figures, not live rates. */
const RATES = { GBP: 1, EUR: 1.137, AED: 4.82, INR: 116.3 };

export default function ExchangeControlled() {
  const [value, setValue] = useState<ExchangeValue>({ from: 'GBP', to: 'INR', amount: 500, side: 'to' });

  return (
    <div className="flex w-full max-w-sm flex-col gap-3">
      <Exchange currencies={CURRENCIES} rates={RATES} value={value} onValueChange={setValue}>
        <Exchange.Field side="from" label="You send" />
        <Exchange.Swap />
        <Exchange.Field side="to" label="They receive" />
      </Exchange>
      <p className="text-xs text-muted-foreground">
        Fixed side: {value.side === 'to' ? 'what they receive' : 'what you send'} ·{' '}
        {value.amount === null ? 'no amount' : `${value.amount} ${value.side === 'to' ? value.to : value.from}`}
      </p>
    </div>
  );
}

A fee of your own

convert replaces rates with a rule of your own. This one takes a 0.6% fee from what arrives, and adds it back when the amount is worked out the other way. detail replaces the rate line under a card with your own text, here to say the fee is included.

'use client';

import { Exchange, type ExchangeCurrency } from '@/components/panelui/exchange';

const CURRENCIES: ExchangeCurrency[] = [
  { code: 'USD', name: 'US dollar', symbol: '$', color: 'chart-2' },
  { code: 'CAD', name: 'Canadian dollar', symbol: '$', color: 'chart-5' },
  { code: 'MXN', name: 'Mexican peso', symbol: '$', color: 'chart-1' },
];

/** Units of each currency to one US dollar. Sample figures, not live rates. */
const RATES: Record<string, number> = { USD: 1, CAD: 1.39, MXN: 18.4 };
const FEE = 0.006;

/** The market rate, less a fee taken from what arrives, and added back the other way. */
const convert = (amount: number, from: string, to: string) => {
  const market = (amount / RATES[from]!) * RATES[to]!;
  return from === 'USD' ? market * (1 - FEE) : market / (1 - FEE);
};

export default function ExchangeFee() {
  return (
    <Exchange currencies={CURRENCIES} convert={convert} defaultValue={{ from: 'USD', to: 'MXN', amount: 1200 }}>
      <Exchange.Field side="from" label="You send" detail="Includes a 0.6% fee" />
      <Exchange.Swap />
      <Exchange.Field side="to" label="They receive" />
    </Exchange>
  );
}

In a card

In a Frame, the frame's header carries what the conversion is for, and the button that acts on it sits under the cards. The Exchange fills the width it is given, up to max-w-sm.

'use client';

import { Button } from '@/components/panelui/button';
import { Exchange, type ExchangeCurrency } from '@/components/panelui/exchange';
import { Frame } from '@/components/panelui/frame';

const CURRENCIES: ExchangeCurrency[] = [
  { code: 'EUR', name: 'Euro', symbol: '€', color: 'chart-1' },
  { code: 'CHF', name: 'Swiss franc', symbol: 'F', color: 'chart-5' },
  { code: 'TRY', name: 'Turkish lira', symbol: '₺', color: 'chart-3' },
  { code: 'USD', name: 'US dollar', symbol: '$', color: 'chart-2' },
];

/** Units of each currency to one euro. Sample figures, not live rates. */
const RATES = { EUR: 1, CHF: 0.935, TRY: 48.4, USD: 1.155 };

export default function ExchangeFramed() {
  return (
    <Frame variant="band" className="w-full max-w-sm">
      <Frame.Header>
        <Frame.Title>Travel money</Frame.Title>
        <Frame.Action>Rate held 10 min</Frame.Action>
      </Frame.Header>
      <Frame.Panel className="flex flex-col gap-4 p-3">
        <Exchange currencies={CURRENCIES} rates={RATES} defaultValue={{ from: 'EUR', to: 'TRY', amount: 300 }}>
          <Exchange.Field side="from" label="You pay" />
          <Exchange.Swap />
          <Exchange.Field side="to" label="You get" />
        </Exchange>
        <Button className="w-full">Order currency</Button>
      </Frame.Panel>
    </Frame>
  );
}

A long list of currencies

The list scrolls once it has more rows than room. It opens on the chosen currency, the arrow keys, Home and End move through it, and Enter chooses. A currency with no minor unit, such as the won, takes decimals: 0.

'use client';

import { Exchange, type ExchangeCurrency } from '@/components/panelui/exchange';

const CURRENCIES: ExchangeCurrency[] = [
  { code: 'USD', name: 'US dollar', symbol: '$', color: 'chart-2' },
  { code: 'EUR', name: 'Euro', symbol: '€', color: 'chart-1' },
  { code: 'GBP', name: 'Pound sterling', symbol: '£', color: 'chart-4' },
  { code: 'JPY', name: 'Japanese yen', symbol: '¥', decimals: 0, color: 'chart-5' },
  { code: 'CHF', name: 'Swiss franc', symbol: 'F', color: 'chart-3' },
  { code: 'CAD', name: 'Canadian dollar', symbol: '$', color: 'chart-5' },
  { code: 'AUD', name: 'Australian dollar', symbol: '$', color: 'chart-4' },
  { code: 'SAR', name: 'Saudi riyal', symbol: 'ر', color: 'chart-3' },
  { code: 'AED', name: 'UAE dirham', symbol: 'د', color: 'chart-1' },
  { code: 'INR', name: 'Indian rupee', symbol: '₹', color: 'chart-2' },
  { code: 'KRW', name: 'South Korean won', symbol: '₩', decimals: 0, color: 'chart-4' },
  { code: 'BRL', name: 'Brazilian real', symbol: 'R', color: 'chart-5' },
];

/** Units of each currency to one US dollar. Sample figures, not live rates. */
const RATES = {
  USD: 1,
  EUR: 0.866,
  GBP: 0.762,
  JPY: 153.2,
  CHF: 0.81,
  CAD: 1.39,
  AUD: 1.53,
  SAR: 3.75,
  AED: 3.6725,
  INR: 88.6,
  KRW: 1432,
  BRL: 5.36,
};

export default function ExchangeMany() {
  return (
    <Exchange currencies={CURRENCIES} rates={RATES} defaultValue={{ from: 'KRW', to: 'USD', amount: 85000 }}>
      <Exchange.Field side="from" label="You pay" />
      <Exchange.Swap />
      <Exchange.Field side="to" label="You receive" />
    </Exchange>
  );
}

API Reference

Exchange

PropTypeDefaultDescription
currenciesreadonly ExchangeCurrency[]—Every currency that can be chosen, in the order the list shows them.
ratesReadonly<Record<string, number>>—How many units of each currency one unit of a common base is worth, keyed by code: { USD: 1, EUR: 0.92, JPY: 151.4 }. Any base works, since only the ratio between two entries is used.
convert(amount: number, from: string, to: string) => number—Works out an amount in another currency, in place of rates: for a fee, a spread, or a rate you fetch per pair. Return NaN when there is no rate.
valueExchangeValue—The currencies and the amount, when you hold them.
defaultValueExchangeValue—The currencies and the amount to start with, when the control holds them.
onValueChange(value: Required<ExchangeValue>) => void—Called with every change: a figure typed, a currency chosen, a swap.
localestring'en-US'The locale figures are written in. Default en-US.
disabledbooleanfalseShows the figures but takes no input.

Exchange.Field

PropTypeDefaultDescription
sideExchangeSide—Which card this is.
labelReactNode—What the card is, above its amount: "You pay", "They receive".
placeholderstring—Shown in an empty field. Default 0.
detailReactNode—The quieter line under the amount. Defaults to the rate from this card's currency to the other one; pass null for none.

Every part also takes the props of the element it renders, and a className.

Notes

Typing an amount

The field takes digits and one point, and no more decimals than the currency has. Grouping commas are added once the field is left and ignored while typing. Clearing the field leaves both sides empty.

Choosing a currency

The list takes over the whole control while it is open, and the cards are out of the tab order behind it. Choosing the currency already on the other side swaps the two. Escape or the close button shuts the list and puts focus back on the pill.

Rates

rates can use any base, because only the ratio between two entries is used. A pair with no rate shows no amount and no rate line. A rate is written to four significant figures, so a small one such as yen to dollars is not rounded to zero.

Figures

Figures are written in locale, en-US by default, so the digits are Western whatever the device's language. Pass another locale to change how a finished figure is grouped. While typing, the decimal mark is always a point.

Reduced motion

With reduced motion set, the cards change places without travelling, and the list opens without scaling.

Server Components

Exchange is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read Exchange.Field off the import, so each part is also exported by name: ExchangeField is Exchange.Field, and so on for the others.

import { Exchange, ExchangeField, ExchangeSwap } from "@/components/panelui/exchange"

On this page