ProductCurrency converter
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callCurrency 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add currency-converterReact
npx designme add currency-converter --reactThe 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.
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
- The converted amount rolls to each new value as the amount or currencies change.
- Each side opens a menu of
currencieswith flag, code and name, and the current one is checked. - A small flag sits in front of each currency code, in the menus and on the button for each side. USD, EUR, GBP, JPY, CAD and AUD have theirs built in, and a currency of your own takes one through
flag. - Picking the currency already on the other side moves that side to a different currency, so the two never match.
- Swap flips the currencies and carries the converted amount into the amount field.
- The rate line reads like "1 USD = 0.9200 EUR", followed by the
updatedtext. - Each currency sets its own
symbol,rateagainst the base anddecimals, such as 0 for yen, andlocalesets the thousands separators.
- The amount input is labelled Amount and opens a decimal keypad on touch devices.
- Currency menus use
role="menu"withmenuitemradioitems andaria-checkedon the selected currency. - The currency buttons report
aria-expanded, and Escape closes an open menu and returns focus to its button. - From the keyboard, Enter, Space or the down arrow opens a menu with the focus on its picked item, or its first (the up arrow, on its last); the arrow keys move through it and wrap, Home and End jump to the ends, Tab closes it and moves on, and closing it gives the focus back to its button.
- The icon-only swap button has the label "Swap currencies" and a tooltip.
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>
);
}
Props
Everything CurrencyConverter takes.
| Prop | Type | What it does |
|---|---|---|
currencies | CurrencyConverterCurrency[] | The currencies to pick from, with their rates. The component never fetches rates itself |
amount | string | The text in the amount field, like "24.00". Leave it out to let the field keep it |
defaultAmount | string | |
onAmountChange | (amount: string) => void | |
from | string | The code of the currency paid in |
defaultFrom | string | |
onFromChange | (code: string) => void | |
to | string | The code of the currency it converts to |
defaultTo | string | |
onToChange | (code: string) => void | |
payLabel | string | The label over the amount |
seeLabel | string | The label over the converted amount |
updated | string | After the rate, like "updated 2 min ago". Leave it empty to show only the rate |
locale | string | For the thousands separators in converted amounts |
className | string |
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();
});
});
Questions
What people ask about it.
- Where do the exchange rates come from?
- From you. The component never fetches rates. Pass
currencieswith aratefor each, measured against a base currency whose rate is 1, and setupdatedto 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: 0on that currency. The built-in list already does this for JPY.
More in Product
Pricing, onboarding, setup flows and spending limits for the rest of your product.