Currency Converter
A two-way money converter: type in either field and the other recomputes through a rate table you pass in, with per-currency Intl decimals, a rate breakdown line and an as-of / stale note.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/currency-converter.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CurrencyConverter" component
(lucide-react for two icons; every number and date comes from the platform
Intl API — no money or date library, no fetching of any kind).
Contract
- forwardRef to the root div, spreading the rest of HTMLAttributes minus
defaultValue / onChange.
- rates: { base: string; quotes: Record<string, number>; asOf: number | Date }.
quotes[code] = how many units of that code one unit of base buys; base's own
rate is an implicit 1. This is data the consumer owns — the component never
fetches, refreshes or caches it.
- now: number | Date, required on purpose. Staleness is measured against this
injected instant, never against a render-time clock read, so the output is a
pure function of props and SSR and hydration cannot disagree.
- Value atom: { amount: number | null; from: string; to: string;
edited: "from" | "to" }. amount is expressed in whichever side `edited`
names; the other side is always derived. Controlled through value +
onValueChange, uncontrolled through defaultValue.
- Options: currencies (defaults to base plus every quoted code), locale
("en-US"), timeZone ("UTC"), staleAfterMs (3_600_000), disabled, label,
className.
- Also export the maths as plain functions so callers can reuse it:
crossRate(rates, from, to): number | null and
convertAmount(rates, amount, from, to): number | null.
Behavior
- Cross rate: quote(to) / quote(from), both legs triangulated through the
table's own base, so a EUR-quoted table converts JPY→GBP without EUR ever
appearing on screen. A quote that is missing, zero, negative or non-finite is
not a rate: crossRate returns null.
- Derived amount: amount × rate when edited is "from", amount ÷ rate when it is
"to". The unrounded number is what converts; rounding happens only at the
display and at commit time.
- Typing transfers authority. Every keystroke sanitizes, parses and emits, so
the opposite field is live — the field under the caret becomes `edited` and
the other one goes back to being derived. Nothing debounces; there is no
async step to race.
- Sanitize, never reformat while typing: keep digits and at most one decimal
mark — the locale's own, read from Intl.NumberFormat().formatToParts(1.1) —
and drop everything else in place. Pasting "1,234.56" or "€ 1 234" lands as
an editable number, and a grouping separator can never appear mid-word and
shove the caret. "" and a lone decimal mark both parse to null: an empty
field is never 0.
- Two display modes per field: ungrouped plain digits while that field holds
focus, Intl grouping with the currency's own fixed decimals when it does not.
- Settle on blur and on Enter: round the typed amount to the edited currency's
decimals and emit only if the number actually changed. Enter must not call
preventDefault — a converter inside a form has no business swallowing the
form's Enter.
- Swap trades the two currency codes and nothing else. The number stays in the
field it was typed into, so the edited side stays authoritative and no value
is yanked out from under the caret; the derived side recomputes at the new
pair. Focus stays on the swap button, which never unmounts.
- Decimals per currency come from
Intl.NumberFormat(locale, {style:"currency", currency}).resolvedOptions()
.maximumFractionDigits — 0 for JPY and KRW, 3 for KWD, 2 for most. Never a
hand-kept table. Intl throws RangeError on a code that is not three letters,
so wrap it and fall back to 2: rates arrive as data and a typo must not take
the control down.
- Refusal: when the table cannot price one of the two codes, the derived field
goes blank (never NaN, never 0), the rate line is replaced by a sentence
naming the unpriced codes, and the pickers stay live so the user can choose a
pair that works. Refusal is a state, not a dead end.
- Staleness: age = max(0, now − asOf) — a quote stamped in the future is early,
not stale. Past staleAfterMs, add a note reading "these quotes are N units
old", where N and the unit come from Intl.NumberFormat with style "unit" in
the largest unit that fits (day → hour → minute → second).
- The as-of line prints with explicit date/time components plus timeZoneName in
a fixed timeZone (dateStyle/timeStyle cannot be combined with timeZoneName —
Intl throws). A fixed zone is what keeps the server string and the browser
string identical; an unknown zone throws, so catch and drop the line rather
than crash.
- A value naming a code the picker does not list still renders: prepend it to
that select's options, or the select paints blank and the pair is lost.
- Keyboard map: Tab reaches from-amount → from-currency → swap → to-amount →
to-currency in reading order; Enter settles the field it is in; Escape and
the arrow keys belong to the native selects. Every path is a real control —
there is no gesture, drag or hover-only affordance anywhere.
- ARIA: root is role="group" with an accessible name. Each amount input is
labelled by a real <label> whose visible word is From / To followed by a
visually-hidden "amount in <code>", so the accessible name carries the
currency that otherwise exists only inside the sibling picker; each input is
described by the footer note (rate line, as-of line, stale note). Each select
carries its own aria-label, and the swap button's label states the pair it
will produce. Because a field whose value is recomputed announces nothing,
one polite aria-atomic role="status" region carries the whole sentence —
"€250.00 equals $272.80" — after every edit, currency change and swap.
- disabled is inert, never the native disabled attribute: inputs go readOnly,
selects and the swap button get aria-disabled plus a handler guard, so a
control can never go dead under the user's focus.
- Cleanup: there is nothing to clean. No timers, no intervals, no rAF, no
listeners, no observers — staleness is derived from the injected instant, so
the component never subscribes to a clock. Keep it that way: if you add a
ticking "age" display, cancel the interval on unmount and on prop change.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the shell,
bg-background + border for the two field boxes, bg-muted for the inert state,
text-muted-foreground for labels and the as-of line, text-destructive for the
refusal, var(--chart-4) for the stale note, ring / focus-visible:ring-ring
for focus, accent for hover. No hex, no rgb, no oklch — dark mode is free.
- Field: h-11 box, amount input text-base + tabular-nums so digits do not jitter
while typing, inputMode="decimal" for a numeric keypad, type="text" (a number
input would fight Intl formatting), currency select sitting inside the same
box on the trailing edge.
- The swap button is a circle centred on a hairline divider between the two
fields; its icon rotates half a turn per press under
transition-transform + motion-reduce:transition-none — decoration only, the
swap works identically with motion off.
- Merge the consumer className onto the root through cn().
Customization levers
- Rate source shape: keep { base, quotes, asOf } and adapt your feed in the
parent; if your API already ships pair rates, replace crossRate's body only —
every other part reads it through that one function.
- staleAfterMs: seconds for a trading desk, hours for a pricing page, Infinity
to drop the stale note entirely. timeZone: pass your app's zone to print the
as-of line locally instead of in UTC.
- Precision: RATE_DIGITS drives the "1 EUR = 1.0912 USD" line only; per-amount
decimals stay Intl's business. Add the inverse rate as a second line if your
users think in both directions.
- Money storage: if your backend keeps minor units (cents, satang), multiply on
the way out and divide on the way in inside the parent — the component works
in major units, and rounding here is display rounding, not ledger rounding.
- Layout density: h-11 / p-4 / gap tokens are the sizing knobs; drop max-w-sm
for a full-width panel, or put the two fields side by side on wide screens
with the swap button rotated 90 degrees.
- Extra blocks: a fee or spread row under the rate line, a "reverse" preview,
or a small sparkline of the last N quotes — all of them read the same
ConversionValue atom and need no change to the maths.
- Sign: the sanitizer drops "-" on purpose; allow a leading minus if you are
converting refunds or ledger deltas.Concepts
- Edited side is authoritative — exactly one field holds the number a human typed; the other is a projection of it.
editedtravels inside the value atom, which is why a swap can trade the currencies without deciding whose number wins. - Cross rate through the base — every pair is priced as
quote(to) / quote(from), so one table quoted against a single base covers every combination, and a missing leg fails loudly asnullinstead of quietly asNaN. - Injected instant — the as-of line and the stale verdict are measured against a
nowhanded in as a prop, never a clock read during render; that is what makes the card a pure function of its props and keeps SSR and hydration in agreement. - Refusal as a state — an unpriced pair blanks the derived field and says which code it cannot price, while both pickers stay live; the user can steer out of the dead end without the component ever inventing a number.
- Decimals are asked, not assumed —
resolvedOptions().maximumFractionDigitstells the component that JPY has no minor unit and KWD has three, so zero-decimal currencies stop being a special case in your code. - Sanitize, don't reformat — keystrokes are filtered in place and only normalized once focus leaves, so a thousands separator can never appear mid-word and move the caret, and pasting a formatted amount still works.
Spin Wheel
A prize wheel that reveals an outcome drawn somewhere else — weighted SVG slices, a flick or a button to start, ticking deceleration onto a pre-computed angle, and a one-shot settle.
Column Profile
A per-column profiling panel — inferred type, missing rate, distinct count and a mini distribution per column, expandable into percentiles, top values or a time span.