Money
Editable money: a currency select and amount input working as one
field. Changing currency updates the input's decimal precision live
(USD 1250.00 → JPY 1250 → BHD 1250.000) from a built-in ISO 4217
table — overridable when your app supplies its own. The read-only
counterpart is Amount.
Not for a fixed-currency value — a rate, a fee percentage, a
price your app always quotes in one currency: the select adds a decision the
field never needs, and a plain .bo-input--numeric is the whole
answer.
The rule for this family: displaying a value is Amount's job; capturing one is Money's (currency) or Quantity's (count). One display component for every numeric value; two entry components, split by what the value is — and when it is neither a selectable currency nor a steppable count (a rate, a fixed-currency price, a percentage), a plain .bo-input--numeric is the whole answer: no fourth component, just the input modifier.
Basic — precision follows the currency
View code
<div class="bo-money">
<select class="bo-select bo-money__currency" aria-label="Currency">
<option>USD</option>
<option>EUR</option>
<option>JPY</option>
<option>BHD</option>
</select>
<input class="bo-input bo-input--numeric bo-money__amount" type="number"
step="0.01" value="1250.00" aria-label="Amount">
</div> Switch to JPY: step becomes 1
and 1250.00 trims to 1250 — a lossless reformat. Values that
don't fit the new precision keep their digits (nothing is ever
rounded away); switch to BHD and it pads to 3 decimals. Everything
not in the built-in exception table uses 2.
Currency after the amount
View code
<!-- Currency AFTER the amount: de-DE writes 1.234,56 € where
en-US writes $1,234.56. Same two elements, same classes — the order you
write them in is the order they render. No modifier to remember. -->
<div class="bo-money">
<input class="bo-input bo-input--numeric bo-money__amount" type="number"
step="0.01" value="1250.00" aria-label="Amount">
<select class="bo-select bo-money__currency" aria-label="Currency">
<option>EUR</option>
<option>USD</option>
<option>JPY</option>
</select>
</div> Currency placement is a locale convention, not
a preference: de-DE writes 1.234,56 € where en-US
writes $1,234.56. Put the select after the input
and it renders that way — the joint follows DOM order, so there is no
modifier to remember and no way for a class to disagree with your markup.
Tab order follows too, because nothing is visually reordered. The
behaviour is unchanged: precision still tracks the currency from either
position.
Long currency lists — use a combobox in the same slot
- EUR — Euro
- USD — US dollar
- GBP — Pound sterling
- NOK — Norwegian krone
- JPY — Japanese yen
View code
<!-- A searchable currency, for the list that is 180 long.
Same slot as the select; the combobox is the framework's value-help
control, so nothing new is introduced. No data-open-on-focus, so the
list stays shut until the first character is typed. -->
<div class="bo-money">
<input class="bo-input bo-money__amount" type="text" inputmode="decimal"
value="1250.00" aria-label="Amount">
<div class="bo-combobox bo-money__currency" data-name="currency">
<input class="bo-input" type="text" role="combobox" aria-expanded="false"
aria-controls="cur-list" aria-autocomplete="list" aria-label="Currency"
value="EUR">
<ul class="bo-combobox__listbox" id="cur-list" role="listbox" popover>
<li class="bo-combobox__option" role="option" id="cur-eur" data-value="EUR">EUR — Euro</li>
<li class="bo-combobox__option" role="option" id="cur-usd" data-value="USD">USD — US dollar</li>
<li class="bo-combobox__option" role="option" id="cur-gbp" data-value="GBP">GBP — Pound sterling</li>
<li class="bo-combobox__option" role="option" id="cur-nok" data-value="NOK">NOK — Norwegian krone</li>
<li class="bo-combobox__option" role="option" id="cur-jpy" data-value="JPY">JPY — Japanese yen</li>
</ul>
</div>
</div> A select is right for five
currencies and wrong for a hundred and eighty; unit-of-measure lists in a
real ERP run to hundreds.
Put a combobox where
the select goes — the joint reaches the input inside it, so the
field still reads as one control. Nothing new is introduced: the combobox
is already this framework's value-help surface, with its keyboard
contract in the generated
keymap.
The list stays shut until you type. That is the default,
not a setting: data-open-on-focus is opt-IN, so a combobox
without it opens on the first character rather than dropping two hundred
options the moment the field is focused. Add the attribute for the short
lists where seeing every option immediately is the point.
A currency that is not on the list
View code
<!-- The mechanism already ships: aria-invalid is the
programmatic channel, .bo-form-field__message the visible one. What counts
as a valid code is the server's call, not CSS's. -->
<div class="bo-form-field">
<label class="bo-form-field__label" for="cur-bad">Order value</label>
<div class="bo-money">
<input class="bo-input bo-money__amount" type="text" inputmode="decimal"
value="1250.00" aria-label="Amount">
<div class="bo-combobox bo-money__currency" data-name="currency">
<input class="bo-input" id="cur-bad" type="text" role="combobox"
aria-expanded="false" aria-autocomplete="list" aria-label="Currency"
aria-invalid="true" aria-describedby="cur-bad-msg" value="EURO">
</div>
</div>
<p class="bo-form-field__message" id="cur-bad-msg">EURO is not a currency code. Did you mean EUR?</p>
</div> A combobox lets people TYPE, so people type
EURO, US$ and yesterday's currency code. Nothing
new is needed to say no: aria-invalid="true" on the input is
the programmatic channel, .bo-form-field reacts to it with
:has(), and .bo-form-field__message is the
visible one — the same two channels every other field in this framework
uses. Whether a code is valid is your server's call, not a
CSS one: ISO 4217 has 180 entries, it changes, and half of ERP money
fields are additionally restricted to the currencies a company is
configured for.
Say what is wrong, not that something is. "Not a currency" leaves the reader to guess whether they mistyped or the currency is genuinely unavailable here — and those have different fixes. Naming the nearest match turns a rejection into a correction.
Read-only display — use Amount
View code
<!-- an amount, read-only: Amount with a currency -->
<span class="bo-amount">
<span class="bo-amount__currency">$</span>
<span class="bo-amount__value">1,250.00</span>
</span> There is no .bo-money--display —
same shape as Quantity's
own "use Amount" rule. Once a value stops being edited, it's a display
concern, and Amount already
handles currency symbols, grouping, minor units, and negative/credit
formatting — building a second read-only variant here would just be
.bo-amount again under a different name. The visual
difference is the affordance: a bordered box says "you can type here",
plain text says "this is a fact" — the two are meant to look unrelated.
Your data wins — data-decimals override
View code
<div class="bo-money">
<select class="bo-select bo-money__currency" aria-label="Currency">
<option>USD</option>
<option data-decimals="4">USD — FX rate</option>
</select>
<input class="bo-input bo-input--numeric bo-money__amount" type="number"
step="0.01" value="1.0842" aria-label="Rate">
</div> An option (or the container) carrying
data-decimals beats the built-in table — here an FX-rate
field forces 4 decimals on USD. Supply it wherever your currency
master data disagrees with ISO 4217.
Grouped display — data-grouped
View code
<!-- data-grouped: thousands separators, formatted ON BLUR.
The name moves to a generated hidden input that always submits the RAW
number — the server never parses "1,234,567.50". -->
<div class="bo-money">
<select class="bo-select bo-money__currency" aria-label="Currency">
<option>USD</option>
<option>JPY</option>
</select>
<input class="bo-input bo-input--numeric bo-money__amount" type="number"
step="0.01" value="1234567.5" name="net-total" data-grouped
data-locale="en-US" aria-label="Amount">
</div>
<!-- + initGroupedNumber() once, alongside initMoneyField() --> Formats on blur, never while you
type — the pattern real accounting software ships (live
reformatting is where caret jumps and locale bugs live; the evidence
is linked from the API notes). Focus shows the raw value for editing;
leaving the field renders 1,234,567.50. Grouping is
Intl-driven via data-locale — under
en-IN the same value reads 12,34,567.50 —
and the submitted value is always the raw number via a generated
hidden input. Works the same on
Quantity and any plain
numeric input. Without JS the field stays a native number input:
correct, just ungrouped.
Inside a form field
Precision follows the currency — try JPY (0) or KWD (3).
View code
<div class="bo-form-field" style="max-inline-size: 20rem">
<label class="bo-form-field__label" for="po-total-amount">Order total</label>
<div class="bo-money">
<select class="bo-select bo-money__currency" aria-label="Currency">
<option>SGD</option>
<option>THB</option>
<option>JPY</option>
<option>KWD</option>
</select>
<input class="bo-input bo-input--numeric bo-money__amount" id="po-total-amount"
type="number" step="0.01" value="18940.50" aria-label="Amount">
</div>
<p class="bo-form-field__hint">Precision follows the currency — try JPY (0) or KWD (3).</p>
</div> Composes .bo-form-field
normally — label, hint, and validation states all apply. The two
controls keep their own focus rings and accessible names.
Currency decimals reference
The built-in table stores only the ISO 4217 exceptions — every currency not listed here uses 2 decimals (USD, EUR, GBP, SGD, THB, …).
| Decimals | Currencies |
|---|---|
| 0 | BIF CLP DJF GNF ISK JPY KMF KRW PYG RWF UGX UYI VND VUV XAF XOF XPF |
| 3 | BHD IQD JOD KWD LYD OMR TND |
| 4 | CLF UYW |
| 2 | All other ISO 4217 currencies (the default) |
Programmatic access:
currencyDecimals('JPY') → 0, exported next to
initMoneyField. The authoritative source is the
ISO 4217
amendment list — if an amendment moves a currency, your
data-decimals override covers the gap until the table
catches up.
In a table column
| Line item | Amount | Currency |
|---|---|---|
| Standing desk | USD | |
| Import duty | JPY |
View code
<!-- In a line table the currency is its own COLUMN, fixed per row —
so each amount cell is a plain numeric input (the family rule: fixed
currency needs no widget), with step server-rendered per that row's
currency. -->
<table class="bo-data-table">
<thead><tr>
<th>Line item</th>
<th class="bo-data-table__col--numeric">Amount</th>
<th>Currency</th>
</tr></thead>
<tbody>
<tr>
<td>Standing desk</td>
<td class="bo-data-table__col--numeric">
<input class="bo-input bo-input--numeric" type="number" step="0.01"
value="1208.00" aria-label="Amount, standing desk" />
</td>
<td class="bo-data-table__col--code">USD</td>
</tr>
<tr>
<td>Import duty</td>
<td class="bo-data-table__col--numeric">
<input class="bo-input bo-input--numeric" type="number" step="1"
value="56000" aria-label="Amount, import duty" />
</td>
<td class="bo-data-table__col--code">JPY</td>
</tr>
</tbody>
</table> In a line table, split the pair: the
currency is its own column, fixed per row, and each
amount cell is a plain .bo-input--numeric — the family
rule's fixed-currency case, no widget needed. Precision is the
step the server renders per that row's currency (0.01 for
the USD line, 1 for JPY). The joined .bo-money control is
the form shape — it belongs in a cell only in the rare case
the user must change a row's currency inline.
Large-target (warehouse-floor) variant
View code
<div data-density="spacious">
<div class="bo-money">
<select class="bo-select bo-money__currency" aria-label="Currency">
<option>USD</option>
<option>EUR</option>
</select>
<input class="bo-input bo-input--numeric bo-money__amount" type="number"
step="0.01" value="1250.00" aria-label="Amount">
</div>
</div> There's no separate "large" modifier —
density IS the size variant, same as
Quantity.
data-density="spacious" (44px controls) already meets the
WCAG 2.5.8 minimum target size; scope it to a receiving-floor or
kiosk screen the same way you'd scope compact to a dense
table.
Markup
<!-- Two real form controls working as one field: the select picks the
currency, the behavior re-derives the input's step/decimals from it.
Decimal source, in priority order:
1. data-decimals on the selected <option> (your override)
2. data-decimals on the .bo-money container (your override)
3. the built-in ISO 4217 minor-units table (default 2) -->
<div class="bo-money">
<select class="bo-select bo-money__currency" aria-label="Currency">
<option>USD</option>
<option>JPY</option>
</select>
<input class="bo-input bo-input--numeric bo-money__amount" type="number"
step="0.01" value="0.00" aria-label="Amount">
</div>
<script type="module">
import { initMoneyField } from "@busy-office/ui/js";
initMoneyField();
</script> Quick reference generated
| Class | Role |
|---|---|
| .bo-combobox | block (root) |
| .bo-input | block (root) |
| .bo-money | block (root) |
| .bo-money__amount | part: amount |
| .bo-money__currency | part: currency |
API generated from CSS
| Blocks | bo-comboboxbo-inputbo-money |
|---|---|
| Parts | bo-money__amountbo-money__currency |
| JS required | initMoneyField() — on currency change, re-derives the amount input's step/decimals (option data-decimals ▸ container data-decimals ▸ built-in ISO 4217 table ▸ 2) and reformats the current value LOSSLESSLY only: pads or trims zeros, never rounds — a value that doesn't fit the new precision (a JPY-priced 12.40) keeps its digits and surfaces through the native step-mismatch channel instead. A lossless reformat dispatches a real input event so dirty-tracking (row-edit) sees the change. |
- The built-in currency table is a deliberate exception to the "your app owns the data" rule — kept tiny (exceptions only, everything else 2) and always overridable via
data-decimals. - Both controls need accessible names —
aria-labelon each, or a wrapping.bo-form-fieldlabel naming the pair. - Formatting for display (thousands separators, symbol placement) remains your app’s job — the input holds a plain number;
Intl.NumberFormatat render time is the tool. - The reformat is a programmatic value change with no reliable screen-reader announcement channel (WCAG 4.1.3) — when totals or other UI depend on it, announce the consequence in a committed-change
aria-livestatus (the editable-grid pattern shows the recipe); the field itself reads its new value on next focus/arrow. - The JS is optional, not required. Without
initMoneyField()the two controls still work — precision just doesn't follow the currency automatically. An htmx app that prefers the server owning precision can skip the behavior entirely and re-render the amount input with the newstepon currency change (hx-geton the select) — the same outcome, server-side. And a single-currency form needs neither: a plain.bo-input--numericwith the currency in the label (examples/po-appcaptures every amount this way).
Maturity generated
- Introduced
- v0.3.0
- CSS floor
- Chrome/Edge 99 · Firefox 97 · Safari 15.4
- Alignment scored
- 2026-08-21
- Assistive-tech evidence
- None recorded
Pin this component's first release with npm i @busy-office/ui@0.3.0.
The floor is derived from this component's stylesheet alone — set by
@layer — so it sits at or below the
whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5.
No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour.
What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see
accessibility.
Design-system alignment generated
100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-21.
| Rule | Score | Evidence |
|---|---|---|
| Typography | 3 / 3 | font-size only via --bo-density-font-size (inherited from .bo-input) |
| Colour | 3 / 3 | zero raw hex in money.css; pairs covered by check:contrast |
| Spacing | 3 / 3 | 9rem cap and 6rem floor both carry their reason; gap/margins from tokens (94.2) |
| Interaction | 3 / 3 | native select + input; lossless reformat dispatches real input events so dirty-tracking composes |
| Content | 3 / 3 | the opener says a fixed-currency value (a rate, a fee, a percentage) is wrong for this — .bo-input--numeric is the whole answer, no select needed (102.10) |
| Fit | 3 / 3 | docs name every context per the field matrix, incl. where the joined control does NOT belong (in-cell) |