Skip to content
Star on GitHub
ProductCurrency converter
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Currency converter

Show plan prices in local currency, with a swap and live rate. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add currency-converter
You pay
They see0
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add currency-converter

React

$npx designme add currency-converter --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Currency converter takes an amount in one currency and shows what it comes to in another, with the exchange rate underneath. Each side has a menu to pick a currency, and a swap button flips the two while keeping the same amount of money. It helps people outside your home market understand what a plan or an agent run will cost them.

  • When a pricing or billing page should show plan prices in a buyer's local currency
  • When an agent quotes a cost and the person wants to see it in their own currency
  • When you have your own exchange rates and need a small converter to display them
  • When people should see the rate and how recently it was updated
03

Usage

Import it and drive it from your own state.

Import

import { CurrencyConverter } from '@/components/designme/currency-converter/CurrencyConverter';

Example

The demo above as a React component, 33 lines you can copy.

'use client';
// The preview from designme.dev, rebuilt from the React component: an example of keeping its values in your own state.
import { useRef, useState } from 'react';
import { useStory } from '../lib/useScript';
import { CurrencyConverter, CurrencyConverterCurrencies } from './CurrencyConverter';

const rate = (code: string) => CurrencyConverterCurrencies.find(c => c.code === code)?.rate ?? 1;

/* ---------- Currency converter: $24 in euros; type, pick currencies or swap them ---------- */
export default function CurrencyConverterDemo() {
  const [amount, setAmount] = useState('24.00');
  const [from, setFrom] = useState('USD');
  const [to, setTo] = useState('EUR');
  // once it has appeared it converts a new amount, then swaps the two currencies, carrying the money over. Clicking, focusing
  // or typing in it takes over, and the amount it was halfway through typing goes back to what it was
  const ref = useRef<HTMLDivElement>(null);
  useStory(ref, async ({ wait, taken }) => {
    let typing = false;
    taken(() => { if (typing) setAmount('24.00'); });
    await wait(1500); typing = true;
    for (const typed of ['1', '12', '120']) { await wait(150); setAmount(typed); }
    typing = false;
    await wait(250); setAmount('120.00');
    await wait(1300);
    setAmount((120 / rate('USD') * rate('EUR')).toFixed(2)); setFrom('EUR'); setTo('USD');
  });
  return (
    <div ref={ref} style={{ width: '100%' }}>
      <CurrencyConverter amount={amount} onAmountChange={setAmount} from={from} onFromChange={setFrom} to={to} onToChange={setTo} />
    </div>
  );
}
04

Props

Everything CurrencyConverter takes.

PropTypeWhat it does
currenciesCurrencyConverterCurrency[]The currencies to pick from, with their rates. The component never fetches rates itself
amountstringThe text in the amount field, like "24.00". Leave it out to let the field keep it
defaultAmountstring
onAmountChange(amount: string) => void
fromstringThe code of the currency paid in
defaultFromstring
onFromChange(code: string) => void
tostringThe code of the currency it converts to
defaultTostring
onToChange(code: string) => void
payLabelstringThe label over the amount
seeLabelstringThe label over the converted amount
updatedstringAfter the rate, like "updated 2 min ago". Leave it empty to show only the rate
localestringFor the thousands separators in converted amounts
classNamestring
05

Code

The full source, as the CLI installs it.

CurrencyConverter.tsxReact · 135 lines
'use client';
import { useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { Flag, type FlagCode } from '../lib/flags';
import { useControllable, useTween } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Swap, Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './currency-converter.css';

export interface CurrencyConverterCurrency {
  /** Like "USD" */
  code: string;
  /** Like "US dollar", shown in the menu */
  name: string;
  /** Put before converted amounts, like "$". Defaults to the code and a space */
  symbol?: string;
  /** How much of this currency one unit of the base buys. The base itself is 1 */
  rate: number;
  /** Decimals in converted amounts. 2 by default, 0 for yen */
  decimals?: number;
  /** Your own flag, shown in front of the code. USD, EUR, GBP, JPY, CAD and AUD have theirs built in */
  flag?: ReactNode;
}

/** The currencies and rates the site's card uses, against the US dollar */
export const CurrencyConverterCurrencies: CurrencyConverterCurrency[] = [
  { code: 'USD', name: 'US dollar', symbol: '$', rate: 1 },
  { code: 'EUR', name: 'Euro', symbol: '€', rate: .92 },
  { code: 'GBP', name: 'Pound', symbol: '£', rate: .79 },
  { code: 'JPY', name: 'Yen', symbol: '¥', rate: 149.6, decimals: 0 },
  { code: 'CAD', name: 'Canadian dollar', symbol: 'C$', rate: 1.37 },
  { code: 'AUD', name: 'Australian dollar', symbol: 'A$', rate: 1.52 }
];

/* each built-in currency's flag: the ones Country select draws, and the European Union's twelve stars, drawn here the same way */
const COUNTRY: Record<string, FlagCode> = { USD: 'US', GBP: 'GB', JPY: 'JP', CAD: 'CA', AUD: 'AU' };
const EU = '<svg viewBox="0 0 640 480"><path fill="#039" d="M0 0h640v480H0z"/><path fill="#fc0" d="M320 53.3 326 71.8 345.4 71.8 329.7 83.1 335.7 101.6 320 90.2 304.3 101.6 310.3 83.1 294.6 71.8 314 71.8zM400 74.8 406 93.2 425.4 93.2 409.7 104.6 415.7 123 400 111.6 384.3 123 390.3 104.6 374.6 93.2 394 93.2zM458.6 133.3 464.6 151.8 483.9 151.8 468.3 163.1 474.2 181.6 458.6 170.2 442.9 181.6 448.9 163.1 433.2 151.8 452.6 151.8zM480 213.3 486 231.8 505.4 231.8 489.7 243.1 495.7 261.6 480 250.2 464.3 261.6 470.3 243.1 454.6 231.8 474 231.8zM458.6 293.3 464.6 311.8 483.9 311.8 468.3 323.1 474.2 341.6 458.6 330.2 442.9 341.6 448.9 323.1 433.2 311.8 452.6 311.8zM400 351.9 406 370.3 425.4 370.3 409.7 381.7 415.7 400.1 400 388.8 384.3 400.1 390.3 381.7 374.6 370.3 394 370.3zM320 373.3 326 391.8 345.4 391.8 329.7 403.1 335.7 421.6 320 410.2 304.3 421.6 310.3 403.1 294.6 391.8 314 391.8zM240 351.9 246 370.3 265.4 370.3 249.7 381.7 255.7 400.1 240 388.8 224.3 400.1 230.3 381.7 214.6 370.3 234 370.3zM181.4 293.3 187.4 311.8 206.8 311.8 191.1 323.1 197.1 341.6 181.4 330.2 165.8 341.6 171.7 323.1 156.1 311.8 175.4 311.8zM160 213.3 166 231.8 185.4 231.8 169.7 243.1 175.7 261.6 160 250.2 144.3 261.6 150.3 243.1 134.6 231.8 154 231.8zM181.4 133.3 187.4 151.8 206.8 151.8 191.1 163.1 197.1 181.6 181.4 170.2 165.8 181.6 171.7 163.1 156.1 151.8 175.4 151.8zM240 74.8 246 93.2 265.4 93.2 249.7 104.6 255.7 123 240 111.6 224.3 123 230.3 104.6 214.6 93.2 234 93.2z"/></svg>';
const flagOf = (c: CurrencyConverterCurrency) => (
  <span className="dm-ccx-fl">
    {c.flag ?? (COUNTRY[c.code] ? <Flag code={COUNTRY[c.code]} />
      : c.code === 'EUR' ? <img className="dm-flag" alt="" src={'data:image/svg+xml,' + encodeURIComponent(EU.replace('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid slice" '))} />
      : <span className="dm-flag" />)}
  </span>
);

export interface CurrencyConverterProps {
  /** The currencies to pick from, with their rates. The component never fetches rates itself */
  currencies?: CurrencyConverterCurrency[];
  /** The text in the amount field, like "24.00". Leave it out to let the field keep it */
  amount?: string;
  defaultAmount?: string;
  onAmountChange?: (amount: string) => void;
  /** The code of the currency paid in */
  from?: string;
  defaultFrom?: string;
  onFromChange?: (code: string) => void;
  /** The code of the currency it converts to */
  to?: string;
  defaultTo?: string;
  onToChange?: (code: string) => void;
  /** The label over the amount */
  payLabel?: string;
  /** The label over the converted amount */
  seeLabel?: string;
  /** After the rate, like "updated 2 min ago". Leave it empty to show only the rate */
  updated?: string;
  /** For the thousands separators in converted amounts */
  locale?: string;
  className?: string;
}

/** Converts an amount between two currencies, with a menu for each and a button that swaps them. */
export function CurrencyConverter({ currencies = CurrencyConverterCurrencies, amount, defaultAmount = '24.00', onAmountChange, from, defaultFrom = 'USD', onFromChange,
  to, defaultTo = 'EUR', onToChange, payLabel = 'You pay', seeLabel = 'They see', updated = 'updated 2 min ago', locale = 'en-US', className }: CurrencyConverterProps) {
  const [text, setText] = useControllable(amount, defaultAmount, onAmountChange);
  const [a, setA] = useControllable(from, defaultFrom, onFromChange);
  const [b, setB] = useControllable(to, defaultTo, onToChange);
  const [turned, setTurned] = useState(false);
  const pay = usePopover({ align: 'end' }), see = usePopover({ align: 'end' });
  const cur = (code: string) => currencies.find(c => c.code === code) ?? currencies[0];
  const A = cur(a), B = cur(b);

  const n = parseFloat(text.replace(/[^\d.]/g, '')) || 0;
  // the converted amount rolls to each new value, starting from 0 when the card appears
  const shown = useTween(n / A.rate * B.rate, 450, 0);
  const dp = B.decimals ?? 2;
  const out = (B.symbol ?? B.code + ' ') + shown.toLocaleString(locale, { minimumFractionDigits: dp, maximumFractionDigits: dp });

  // picking the currency already on the other side moves that side to the next one
  const pick = (side: 'from' | 'to', code: string) => {
    const other = side === 'from' ? b : a;
    const next = code === other ? currencies.find(c => c.code !== code)!.code : other;
    if (side === 'from') { setA(code); if (next !== b) setB(next); } else { setB(code); if (next !== a) setA(next); }
  };
  // swapping carries the converted amount over, so the money stays the same
  const swap = () => {
    setTurned(t => !t);
    setText(shown.toFixed(B.decimals ?? 2));
    setA(b); setB(a);
  };

  const menu = (side: 'from' | 'to', p: ReturnType<typeof usePopover>, value: string) => (
    <div {...p.pop} role="menu">
      {currencies.map(c => (
        <button key={c.code} type="button" className="dm-mi" role="menuitemradio" aria-checked={c.code === value} onClick={() => { pick(side, c.code); p.closeSoon(); }}>
          {flagOf(c)}<span className="dm-cd">{c.code}</span><span>{c.name}</span><span className="dm-ck"><Icon name="check" size="xs" /></span>
        </button>
      ))}
    </div>
  );

  return (
    <div data-dm="" className={cx('dm-ccx dm-panel', className)}>
      <div className="dm-ccx-row">
        <div className="dm-ccx-in">
          <span className="dm-muted">{payLabel}</span>
          <input className="dm-ccx-a dm-num" value={text} inputMode="decimal" aria-label="Amount"
            onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))} onBlur={() => setText((parseFloat(text) || 0).toFixed(2))} />
        </div>
        <button type="button" {...pay.trigger} className="dm-ccx-cur">{flagOf(A)}<Swap className="dm-cd">{A.code}</Swap><Icon name="chev-d" size="xs" /></button>
      </div>
      <Tip label="Swap"><button type="button" className={cx('dm-ccx-sw', turned && 'dm-go')} aria-label="Swap currencies" onClick={swap}><Icon name="sort" size="xs" /></button></Tip>
      <div className="dm-ccx-row">
        <div className="dm-ccx-in"><span className="dm-muted">{seeLabel}</span><b className="dm-ccx-b dm-num">{out}</b></div>
        <button type="button" {...see.trigger} className="dm-ccx-cur">{flagOf(B)}<Swap className="dm-cd">{B.code}</Swap><Icon name="chev-d" size="xs" /></button>
      </div>
      <div className="dm-ccx-rate dm-muted dm-num"><span className="dm-dot" />1 {A.code} = {(B.rate / A.rate).toFixed(4)} {B.code}{updated && ` · ${updated}`}</div>
      {menu('from', pay, a)}
      {menu('to', see, b)}
    </div>
  );
}
currency-converter.cssReact · 26 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Currency converter ---------- */
.dm-ccx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:6px;max-width:340px;margin:0 auto;width:100%;position:relative}
.dm-ccx-row{display:flex;align-items:center;gap:var(--dm-s-2);padding:10px 10px 10px 14px;border-radius:var(--dm-r-box);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-ccx-row{corner-shape:squircle;border-radius:var(--dm-r-box-sq)}}
/* the row's edge steps up while its amount field has the focus; its currency button rings on its own */
.dm-ccx-row:has(.dm-ccx-a:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
/* room between the label and the amount */
.dm-ccx-in{flex:1;display:flex;flex-direction:column;gap:var(--dm-s-2);min-width:0;font-size:11.5px}
.dm-ccx-a,.dm-ccx-b{border:0;outline:0;background:transparent;font:inherit;font-size:14px;font-weight:500;letter-spacing:-.02em;color:var(--dm-fg);padding:0;width:100%;min-width:0}
.dm-ccx-cur{display:flex;align-items:center;gap:6px;height:34px;padding:0 10px;border-radius:10px;background:var(--dm-chip);font-size:13px;font-weight:500}
.dm-ccx-cur .dm-cd{font-size:12px}
.dm-ccx-sw{align-self:center;width:32px;height:32px;margin:-16px 0;z-index:1;border-radius:50%;background:var(--dm-surface);box-shadow:var(--dm-shadow),0 0 0 4px var(--dm-surface);display:grid;place-items:center;color:var(--dm-fg2);transition:transform .5s var(--dm-ease),color .2s}
@media (hover:hover) and (pointer:fine){.dm-ccx-sw:hover{color:var(--dm-fg)}}
.dm-ccx-sw.dm-go{transform:rotate(180deg)}
.dm-ccx-rate{font-size:12px;text-align:center;margin-top:var(--dm-s-1);display:flex;align-items:center;justify-content:center;gap:6px}
.dm-ccx-rate .dm-dot{color:var(--dm-ok);animation:dm-ccpulse 2s ease-in-out infinite}
@keyframes dm-ccpulse{50%{opacity:.4}}
.dm-ccx .dm-pop{min-width:180px}
/* a list opens downwards, and fades in a beat after the converter starts to glide up to make room for it */
.dm-ccx.dm-glide>.dm-pop.dm-open{transition-delay:.08s}
.dm-ccx .dm-mi .dm-cd{font-size:11.5px;color:var(--dm-muted);width:32px}
/* each currency's flag, a small disc in front of its code, with a hairline edge so its white parts hold */
.dm-ccx-fl{display:grid;place-items:center;flex:none}
.dm-ccx-fl .dm-flag{width:16px;height:16px;border-radius:50%;box-shadow:0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent)}
currency-converter.htmlHTML · 12 lines
<div class="card" data-c="currency-converter">
  <div class="stage-in">
    <div class="ccx panel">
      <div class="ccx-row"><div class="ccx-in"><span class="muted">You pay</span><input class="ccx-a num" value="24.00" inputmode="decimal" aria-label="Amount"></div><button class="ccx-cur" data-pop=".ccx-p1" data-pop-align="end" data-pop-pos="down"><span class="ccx-fl" data-k="USD"><img class="flag" alt="" src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%20viewBox%3D%220%200%20640%20480%22%3E%20%3Cpath%20fill%3D%22%23bd3d44%22%20d%3D%22M0%200h640v480H0%22%2F%3E%20%3Cpath%20stroke%3D%22%23fff%22%20stroke-width%3D%2237%22%20d%3D%22M0%2055.3h640M0%20129h640M0%20203h640M0%20277h640M0%20351h640M0%20425h640%22%2F%3E%20%3Cpath%20fill%3D%22%23192f5d%22%20d%3D%22M0%200h364.8v258.5H0%22%2F%3E%20%3Cmarker%20markerHeight%3D%2230%22%20markerWidth%3D%2230%22%3E%20%3Cpath%20fill%3D%22%23fff%22%20d%3D%22m14%200%209%2027L0%2010h28L5%2027z%22%2F%3E%20%3C%2Fmarker%3E%20%3Cpath%20fill%3D%22none%22%20marker-mid%3D%22url(%23us-a)%22%20d%3D%22m0%200%2016%2011h61%2061%2061%2061%2060L47%2037h61%2061%2060%2061L16%2063h61%2061%2061%2061%2060L47%2089h61%2061%2060%2061L16%20115h61%2061%2061%2061%2060L47%20141h61%2061%2060%2061L16%20166h61%2061%2061%2061%2060L47%20192h61%2061%2060%2061L16%20218h61%2061%2061%2061%2060z%22%2F%3E%20%3C%2Fsvg%3E"></span><span class="cd">USD</span><svg class="i xs"><use href="#i-chev-d"/></svg></button></div>
      <button class="ccx-sw" aria-label="Swap currencies" data-tip="Swap"><svg class="i xs"><use href="#i-sort"/></svg></button>
      <div class="ccx-row"><div class="ccx-in"><span class="muted">They see</span><b class="ccx-b num">0</b></div><button class="ccx-cur" data-pop=".ccx-p2" data-pop-align="end" data-pop-pos="down"><span class="ccx-fl" data-k="EUR"><img class="flag" alt="" src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%20viewBox%3D%220%200%20640%20480%22%3E%3Cpath%20fill%3D%22%23039%22%20d%3D%22M0%200h640v480H0z%22%2F%3E%3Cpath%20fill%3D%22%23fc0%22%20d%3D%22M320%2053.3%20326%2071.8%20345.4%2071.8%20329.7%2083.1%20335.7%20101.6%20320%2090.2%20304.3%20101.6%20310.3%2083.1%20294.6%2071.8%20314%2071.8zM400%2074.8%20406%2093.2%20425.4%2093.2%20409.7%20104.6%20415.7%20123%20400%20111.6%20384.3%20123%20390.3%20104.6%20374.6%2093.2%20394%2093.2zM458.6%20133.3%20464.6%20151.8%20483.9%20151.8%20468.3%20163.1%20474.2%20181.6%20458.6%20170.2%20442.9%20181.6%20448.9%20163.1%20433.2%20151.8%20452.6%20151.8zM480%20213.3%20486%20231.8%20505.4%20231.8%20489.7%20243.1%20495.7%20261.6%20480%20250.2%20464.3%20261.6%20470.3%20243.1%20454.6%20231.8%20474%20231.8zM458.6%20293.3%20464.6%20311.8%20483.9%20311.8%20468.3%20323.1%20474.2%20341.6%20458.6%20330.2%20442.9%20341.6%20448.9%20323.1%20433.2%20311.8%20452.6%20311.8zM400%20351.9%20406%20370.3%20425.4%20370.3%20409.7%20381.7%20415.7%20400.1%20400%20388.8%20384.3%20400.1%20390.3%20381.7%20374.6%20370.3%20394%20370.3zM320%20373.3%20326%20391.8%20345.4%20391.8%20329.7%20403.1%20335.7%20421.6%20320%20410.2%20304.3%20421.6%20310.3%20403.1%20294.6%20391.8%20314%20391.8zM240%20351.9%20246%20370.3%20265.4%20370.3%20249.7%20381.7%20255.7%20400.1%20240%20388.8%20224.3%20400.1%20230.3%20381.7%20214.6%20370.3%20234%20370.3zM181.4%20293.3%20187.4%20311.8%20206.8%20311.8%20191.1%20323.1%20197.1%20341.6%20181.4%20330.2%20165.8%20341.6%20171.7%20323.1%20156.1%20311.8%20175.4%20311.8zM160%20213.3%20166%20231.8%20185.4%20231.8%20169.7%20243.1%20175.7%20261.6%20160%20250.2%20144.3%20261.6%20150.3%20243.1%20134.6%20231.8%20154%20231.8zM181.4%20133.3%20187.4%20151.8%20206.8%20151.8%20191.1%20163.1%20197.1%20181.6%20181.4%20170.2%20165.8%20181.6%20171.7%20163.1%20156.1%20151.8%20175.4%20151.8zM240%2074.8%20246%2093.2%20265.4%2093.2%20249.7%20104.6%20255.7%20123%20240%20111.6%20224.3%20123%20230.3%20104.6%20214.6%2093.2%20234%2093.2z%22%2F%3E%3C%2Fsvg%3E"></span><span class="cd">EUR</span><svg class="i xs"><use href="#i-chev-d"/></svg></button></div>
      <div class="ccx-rate muted num"></div>
      <div class="pop ccx-p1" role="menu"></div><div class="pop ccx-p2" role="menu"></div>
    </div>
  </div>
</div>
currency-converter.cssHTML · 25 lines
/* ---------- Currency converter ---------- */
.ccx{padding:var(--s-4);display:flex;flex-direction:column;gap:6px;max-width:340px;margin:0 auto;width:100%;position:relative}
.ccx-row{display:flex;align-items:center;gap:var(--s-2);padding:10px 10px 10px 14px;border-radius:var(--r-box);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.ccx-row{corner-shape:squircle;border-radius:var(--r-box-sq)}}
/* the row's edge steps up while its amount field has the focus; its currency button rings on its own */
.ccx-row:has(.ccx-a:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
/* room between the label and the amount */
.ccx-in{flex:1;display:flex;flex-direction:column;gap:var(--s-2);min-width:0;font-size:11.5px}
.ccx-a,.ccx-b{border:0;outline:0;background:transparent;font:inherit;font-size:14px;font-weight:500;letter-spacing:-.02em;color:var(--fg);padding:0;width:100%;min-width:0}
.ccx-cur{display:flex;align-items:center;gap:6px;height:34px;padding:0 10px;border-radius:10px;background:var(--chip);font-size:13px;font-weight:500}
.ccx-cur .cd{font-size:12px}
.ccx-sw{align-self:center;width:32px;height:32px;margin:-16px 0;z-index:1;border-radius:50%;background:var(--surface);box-shadow:var(--shadow),0 0 0 4px var(--surface);display:grid;place-items:center;color:var(--fg2);transition:transform .5s var(--ease),color .2s}
@media (hover:hover) and (pointer:fine){.ccx-sw:hover{color:var(--fg)}}
.ccx-sw.go{transform:rotate(180deg)}
.ccx-rate{font-size:12px;text-align:center;margin-top:var(--s-1);display:flex;align-items:center;justify-content:center;gap:6px}
.ccx-rate .dot{color:var(--ok);animation:ccpulse 2s ease-in-out infinite}
@keyframes ccpulse{50%{opacity:.4}}
.ccx .pop{min-width:180px}
/* a list opens downwards, and fades in a beat after the converter starts to glide up to make room for it */
.ccx.glide>.pop.open{transition-delay:.08s}
.ccx .mi .cd{font-size:11.5px;color:var(--muted);width:32px}
/* each currency's flag, a small disc in front of its code, with a hairline edge so its white parts hold */
.ccx-fl{display:grid;place-items:center;flex:none}
.ccx-fl .flag{width:16px;height:16px;border-radius:50%;box-shadow:0 0 0 1px color-mix(in srgb,var(--line) 50%,transparent)}
currency-converter.jsHTML · 40 lines
DM.def('currency-converter', (r, c) => {
  /* each currency's flag: the ones Country select draws, and the European Union's twelve stars, drawn here the same way */
  const CC = { USD:'US', GBP:'GB', JPY:'JP', CAD:'CA', AUD:'AU' };
  const EU = '<svg viewBox="0 0 640 480"><path fill="#039" d="M0 0h640v480H0z"/><path fill="#fc0" d="M320 53.3 326 71.8 345.4 71.8 329.7 83.1 335.7 101.6 320 90.2 304.3 101.6 310.3 83.1 294.6 71.8 314 71.8zM400 74.8 406 93.2 425.4 93.2 409.7 104.6 415.7 123 400 111.6 384.3 123 390.3 104.6 374.6 93.2 394 93.2zM458.6 133.3 464.6 151.8 483.9 151.8 468.3 163.1 474.2 181.6 458.6 170.2 442.9 181.6 448.9 163.1 433.2 151.8 452.6 151.8zM480 213.3 486 231.8 505.4 231.8 489.7 243.1 495.7 261.6 480 250.2 464.3 261.6 470.3 243.1 454.6 231.8 474 231.8zM458.6 293.3 464.6 311.8 483.9 311.8 468.3 323.1 474.2 341.6 458.6 330.2 442.9 341.6 448.9 323.1 433.2 311.8 452.6 311.8zM400 351.9 406 370.3 425.4 370.3 409.7 381.7 415.7 400.1 400 388.8 384.3 400.1 390.3 381.7 374.6 370.3 394 370.3zM320 373.3 326 391.8 345.4 391.8 329.7 403.1 335.7 421.6 320 410.2 304.3 421.6 310.3 403.1 294.6 391.8 314 391.8zM240 351.9 246 370.3 265.4 370.3 249.7 381.7 255.7 400.1 240 388.8 224.3 400.1 230.3 381.7 214.6 370.3 234 370.3zM181.4 293.3 187.4 311.8 206.8 311.8 191.1 323.1 197.1 341.6 181.4 330.2 165.8 341.6 171.7 323.1 156.1 311.8 175.4 311.8zM160 213.3 166 231.8 185.4 231.8 169.7 243.1 175.7 261.6 160 250.2 144.3 261.6 150.3 243.1 134.6 231.8 154 231.8zM181.4 133.3 187.4 151.8 206.8 151.8 191.1 163.1 197.1 181.6 181.4 170.2 165.8 181.6 171.7 163.1 156.1 151.8 175.4 151.8zM240 74.8 246 93.2 265.4 93.2 249.7 104.6 255.7 123 240 111.6 224.3 123 230.3 104.6 214.6 93.2 234 93.2z"/></svg>';
  const FL = k => `<span class="ccx-fl" data-k="${k}">${k === 'EUR' ? `<img class="flag" alt="" src="data:image/svg+xml,${encodeURIComponent(EU.replace('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid slice" '))}">` : FLAG(CC[k])}</span>`;
  r.innerHTML = r.innerHTML.replace(/\{chev\}/g, I('chev-d','xs')).replace('{swap}', I('sort','xs')).replace(/\{fl-(\w+)\}/g, (m, k) => FL(k));
  const RATE = { USD:1, EUR:.92, GBP:.79, JPY:149.6, CAD:1.37, AUD:1.52 };
  const NAME = { USD:'US dollar', EUR:'Euro', GBP:'Pound', JPY:'Yen', CAD:'Canadian dollar', AUD:'Australian dollar' };
  const SYM = { USD:'$', EUR:'€', GBP:'£', JPY:'¥', CAD:'C$', AUD:'A$' };
  let A = 'USD', B = 'EUR', out = 0;
  const a = $('.ccx-a', r), b = $('.ccx-b', r), rate = $('.ccx-rate', r), curs = $$('.ccx-cur', r);
  function calc(delay){ const n = parseFloat(a.value.replace(/[^\d.]/g, '')) || 0; const v = n / RATE[A] * RATE[B]; const dp = B === 'JPY' ? 0 : 2;
    const go = () => tween(out, v, 450, x => { out = x; b.textContent = SYM[B] + x.toLocaleString('en-US', { minimumFractionDigits:dp, maximumFractionDigits:dp }); }); delay ? c.later(delay, go) : go();
    rate.innerHTML = `<span class="dot"></span>1 ${A} = ${(RATE[B] / RATE[A]).toFixed(B === 'JPY' || A === 'JPY' ? 4 : 4)} ${B} · updated 2 min ago`; }
  function menus(){ [['.ccx-p1', 0], ['.ccx-p2', 1]].forEach(([sel, side]) => { const p = $(sel, r), cur = side ? B : A;
    p.innerHTML = Object.keys(RATE).map(k => `<button class="mi" data-close data-k="${k}" aria-checked="${k === cur}">${FL(k)}<span class="cd">${k}</span><span>${NAME[k]}</span><span class="ck">${I('check','xs')}</span></button>`).join('');
    $$('.mi', p).forEach(m => m.addEventListener('click', () => { if (side) B = m.dataset.k; else A = m.dataset.k; if (A === B){ if (side) A = Object.keys(RATE).find(x => x !== B); else B = Object.keys(RATE).find(x => x !== A); } paint(); })); }); }
  // the code rolls over to the new one while its flag changes at once
  const flag = (cur, k) => { const f = $('.ccx-fl', cur); if (f.dataset.k !== k) f.outerHTML = FL(k); };
  function paint(delay){ flag(curs[0], A); flag(curs[1], B); swap($('.cd', curs[0]), A); swap($('.cd', curs[1]), B); menus(); calc(delay); }
  a.oninput = () => { a.value = a.value.replace(/[^\d.]/g, ''); calc(); };
  a.onblur = () => { const n = parseFloat(a.value) || 0; a.value = n.toFixed(2); };
  const sw = $('.ccx-sw', r);
  sw.onclick = () => { sw.classList.toggle('go'); const n = out; [A, B] = [B, A]; a.value = (B === 'JPY' || A === 'JPY') ? (A === 'JPY' ? Math.round(n) : n.toFixed(2)) : n.toFixed(2); a.value = String(a.value); paint(); };
  paint(260);
  // the converter sits centred on its own, and glides up as a currency list opens under it, so the two sit centred together
  // instead of the list running off the stage; on closing, it waits for the list to fade before it glides back to the middle
  const box = $('.ccx', r), pops = $$('.pop', box), shown = () => pops.find(p => p.classList.contains('open'));
  const centred = centre(c, box, () => { const p = shown(); return p ? Math.max(box.offsetHeight, p.offsetTop + p.offsetHeight) : box.offsetHeight; });
  const mo = new MutationObserver(() => shown() ? centred() : c.later(REDUCE ? 0 : 180, centred));
  pops.forEach(p => mo.observe(p, { attributes:true, attributeFilter:['class'] })); c.offs.push(() => mo.disconnect());
  // once in view it converts a new amount, then swaps the two currencies. Clicking, focusing or typing in it takes over
  c.run(async () => {
    await c.step(1500);
    await c.type(a, '120', { ms:150, each:() => calc(), clear:true });
    await c.step(250); a.value = '120.00';
    await c.step(1300); sw.click();
  });
});
06

Questions

What people ask about it.

Where do the exchange rates come from?
From you. The component never fetches rates. Pass currencies with a rate for each, measured against a base currency whose rate is 1, and set updated to say how fresh they are.
Why does the amount change when I press swap?
Swap keeps the money the same. The converted amount becomes the new amount, so 24 USD shown as 22.08 EUR becomes 22.08 EUR converting back to USD.
How do I show yen without decimals?
Set decimals: 0 on that currency. The built-in list already does this for JPY.
07

More in Product

Pricing, onboarding, setup flows and spending limits for the rest of your product.

Pricing cardsThree plans with a billing switch and a seat slider.
Onboarding checklistFirst steps with a progress ring, and the agent can do some for you.
StepperA multi-step setup with a progress track that fills as you go.
Set limitA monthly agent budget with a draggable cap and a forecast.