Currency amount field
A money amount field that stores integer minor units plus an ISO 4217 code, shows the currency's mark on the side the locale writes it, formats on blur, and can attach a currency select that never converts.
cmp_currency_amount_field_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_currency_amount_field_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-a246d99551d7b58d414a0c77b840668f54724811ff3e1014a1175ca154e0e21e
This component needs all 2 files. Download the ZIP
<!--
A money amount field. The value is an integer in the currency's minor unit plus its ISO 4217
code ({ amountMinor: 1250, currency: 'GBP' } is £12.50), kept apart from the text the person
sees. The currency's mark sits inside the field's one border on the side the locale writes it,
an optional currency select is attached as the field's last segment, and the amount is
formatted with grouping when the person leaves the field, never while they type. The value
submits as two hidden fields, amount_minor and currency.
-->
<script lang="ts" module>
export interface CurrencyAmount {
/** Integer in the currency's minor unit: 1250 GBP is £12.50, 1500 JPY is ¥1,500. */
amountMinor: number;
/** ISO 4217 code. */
currency: string;
}
export type CurrencyAmountFieldSize = 'md' | 'lg';
export type CurrencyAmountFieldAdornment = 'symbol' | 'code';
/**
* Every sentence the field writes itself. {currency} is the currency's name in words in the
* field's locale, {code} its ISO code, {min} and {max} formatted bounds, {example} a formatted
* sample amount and {digits} the currency's decimal places.
*/
export interface CurrencyAmountMessages {
/** Read after the label so the currency is in the field's name: "Your gift (British Pound)". */
currencyName: string;
min: string;
max: string;
range: string;
invalid: string;
decimals: string;
wholeNumber: string;
tooLarge: string;
belowMin: string;
aboveMax: string;
/** Shown and announced after the currency changes. */
changed: string;
}
export const defaultMessages: CurrencyAmountMessages = {
currencyName: '({currency})',
min: 'Minimum {min}.',
max: 'Maximum {max}.',
range: '{min} to {max}.',
invalid: 'Enter an amount in numbers, like {example}.',
decimals: 'Use no more than {digits} decimal places.',
wholeNumber: 'Enter a whole number. {currency} has no smaller unit.',
tooLarge: 'Enter a smaller amount.',
belowMin: 'Enter at least {min}.',
aboveMax: 'Enter no more than {max}.',
changed: '{code} selected. Amount not converted.'
};
</script>
<script lang="ts">
import { flushSync, untrack } from 'svelte';
import type { HTMLInputAttributes } from 'svelte/elements';
import { currencyFormat, formatAmount, formatMoney, parseAmount } from './money';
type Passthrough = Omit<
HTMLInputAttributes,
| 'type'
| 'value'
| 'id'
| 'name'
| 'size'
| 'required'
| 'disabled'
| 'readonly'
| 'inputmode'
| 'placeholder'
| 'class'
| 'children'
| 'aria-invalid'
| 'onblur'
| 'onkeydown'
| 'oninput'
| 'onbeforeinput'
| 'onchange'
>;
interface Props extends Passthrough {
/** Visible label. The currency's name is added for assistive technology. */
label: string;
/** Bindable value in minor units, or null when the field is empty or unreadable. */
value?: CurrencyAmount | null;
/** ISO 4217 code: the field's currency, or the first selection when currencies is set. */
currency: string;
/** Shows a currency select limited to these codes. */
currencies?: string[];
/** Locale for separators, the mark and its side, and the currency's name. */
locale?: string;
/** Smallest allowed amount, in minor units of the selected currency. */
minMinor?: number;
/** Largest allowed amount, in minor units of the selected currency. */
maxMinor?: number;
/** Validation message from your code. Shown instead of the field's own messages. */
error?: string | null;
/** Called with the new value when the person leaves the field or changes the currency. */
onChange?: (value: CurrencyAmount | null) => void;
/** Help text under the label. The bounds follow it. */
hint?: string;
/** 'symbol' shows the locale's sign (£, US$, €); 'code' shows GBP, USD, EUR. */
adornment?: CurrencyAmountFieldAdornment;
/** md is an app field, 36 px; lg is donation-style entry, 56 px with 24 px figures. */
size?: CurrencyAmountFieldSize;
/** Native required on the visible field. */
required?: boolean;
disabled?: boolean;
readonly?: boolean;
/** Placeholder. Defaults to zero in the currency's format: 0.00, 0 or 0,00. */
placeholder?: string;
/** Hidden field carrying amountMinor. */
amountName?: string;
/** Hidden field (or the select's name) carrying the currency code. */
currencyName?: string;
/** Visible label of the currency select. */
currencyLabel?: string;
/** Read before an error by screen readers only. */
errorPrefix?: string;
/** Replace any of the sentences the field writes, for other languages. */
messages?: Partial<CurrencyAmountMessages>;
/** Hold one line under the field for messages. On by default unless disabled or read-only. */
reserveMessage?: boolean;
/** Input id. Defaults to one generated per instance. */
id?: string;
/** Extra classes on the outer wrapper, for placement. */
class?: string;
}
let {
label,
value = $bindable(null),
currency,
currencies,
locale = 'en-US',
minMinor,
maxMinor,
error = null,
onChange,
hint,
adornment = 'symbol',
size = 'md',
required = false,
disabled = false,
readonly = false,
placeholder,
amountName = 'amount_minor',
currencyName = 'currency',
currencyLabel = 'Currency',
errorPrefix = 'Error:',
messages,
reserveMessage,
id,
class: className,
...rest
}: Props = $props();
const uid = $props.id();
const inputId = $derived(id ?? `${uid}-input`);
const selectId = `${uid}-currency`;
const hintId = `${uid}-hint`;
const messageId = `${uid}-message`;
const m = $derived({ ...defaultMessages, ...messages });
const hasSelect = $derived(!!currencies?.length);
let selected = $state(untrack(() => (value?.currency ?? currency).toUpperCase()));
const format = $derived(currencyFormat(locale, selected, adornment));
/* With a select in code mode the select already shows the code, so the field shows no mark. */
const showMark = $derived(!(hasSelect && adornment === 'code'));
const suffix = $derived(showMark && format.position === 'after');
/* What the person sees and edits. Formatted on blur, left alone while they type. */
let text = $state(untrack(() => (value ? formatAmount(value.amountMinor, format) : '')));
let parseError = $state<'invalid' | 'decimals' | 'too-large' | null>(null);
let changed = $state(false);
let announcement = $state('');
/* What a form reset returns to. */
const initial = untrack(() => (value ? { ...value } : null));
/* A snapshot of the last value this field wrote or formatted, so an outside change (a new
object, or a property set through bind:value) can be told apart from its own writes. */
const snapshot = (v: CurrencyAmount | null | undefined) =>
v ? `${v.amountMinor}|${v.currency.toUpperCase()}` : '';
let last = untrack(() => snapshot(value));
let lastFormat = untrack(() => `${locale}|${adornment}`);
let input: HTMLInputElement | undefined = $state();
let select: HTMLSelectElement | undefined = $state();
const fill = (template: string, vars: Record<string, string | number>) =>
template.replace(/\{(\w+)\}/g, (match, key: string) => String(vars[key] ?? match));
const vars = $derived({
currency: format.name,
code: format.code,
digits: format.digits,
min: minMinor != null ? formatMoney(minMinor, format) : '',
max: maxMinor != null ? formatMoney(maxMinor, format) : '',
example: formatAmount(1250 * 10 ** format.digits, format)
});
const boundsText = $derived(
minMinor != null && maxMinor != null
? fill(m.range, vars)
: minMinor != null
? fill(m.min, vars)
: maxMinor != null
? fill(m.max, vars)
: ''
);
const shownPlaceholder = $derived(placeholder ?? formatAmount(0, format));
const ownError = $derived.by(() => {
if (parseError === 'invalid') return fill(m.invalid, vars);
if (parseError === 'decimals')
return fill(format.digits === 0 ? m.wholeNumber : m.decimals, vars);
if (parseError === 'too-large') return fill(m.tooLarge, vars);
if (!value || value.currency.toUpperCase() !== selected) return null;
if (minMinor != null && value.amountMinor < minMinor) return fill(m.belowMin, vars);
if (maxMinor != null && value.amountMinor > maxMinor) return fill(m.aboveMax, vars);
return null;
});
const shownError = $derived(error || ownError);
const invalid = $derived(!!shownError);
/* Any editable field can raise a message of its own (a typo, too many decimals), so it holds
the line by default and nothing below moves when one appears. */
const reserve = $derived(reserveMessage ?? !(disabled || readonly));
const describedBy = $derived(
[
...new Set(
[
(hint || boundsText) && hintId,
(invalid || changed) && messageId,
...(rest['aria-describedby'] ?? '').split(/\s+/)
].filter(Boolean)
)
].join(' ') || undefined
);
/* A value set from outside (bind:value, a reset) replaces the text and the currency. */
$effect.pre(() => {
const next = snapshot(value);
if (next === last) return;
last = next;
untrack(() => {
if (value) selected = value.currency.toUpperCase();
text = value
? formatAmount(value.amountMinor, currencyFormat(locale, selected, adornment))
: '';
parseError = null;
});
});
/* A new locale or mark rewrites a committed amount in the new format. */
$effect.pre(() => {
const next = `${locale}|${adornment}`;
if (next === lastFormat) return;
lastFormat = next;
untrack(() => {
if (value && !parseError) text = formatAmount(value.amountMinor, format);
});
});
/* A selected currency that leaves the list gives way to the list's first, without converting. */
$effect.pre(() => {
const codes = (currencies ?? []).map((code) => code.toUpperCase());
if (!codes.length || codes.includes(untrack(() => selected))) return;
untrack(() => setCurrency(codes[0], false));
});
/* A native form reset returns the field to the value it was created with. */
$effect(() => {
const form = input?.form;
if (!form) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const onReset = () => {
/* The browser resets the controls after the event, so restore once it has. */
timer = setTimeout(() => {
selected = (initial?.currency ?? currency).toUpperCase();
if (select) select.value = selected;
text = initial ? formatAmount(initial.amountMinor, format) : '';
parseError = null;
changed = false;
announcement = '';
write(initial ? { ...initial } : null);
});
};
form.addEventListener('reset', onReset);
return () => {
form.removeEventListener('reset', onReset);
clearTimeout(timer);
};
});
/* Without a select, the currency prop is the currency. */
$effect.pre(() => {
const code = currency.toUpperCase();
if (hasSelect || untrack(() => selected) === code) return;
untrack(() => setCurrency(code, false));
});
/* The field's own errors block native submission, as a native bound would. */
$effect(() => {
input?.setCustomValidity(ownError ?? '');
});
function write(next: CurrencyAmount | null) {
const same =
next === value ||
(!!next &&
!!value &&
next.amountMinor === value.amountMinor &&
next.currency === value.currency);
if (same) return;
last = snapshot(next);
value = next;
onChange?.(next);
}
/** Reads the text in the current (or a new) currency, formats it and writes the value. */
function commit(code = selected) {
const target = currencyFormat(locale, code, adornment);
const result = parseAmount(text, target);
if (!result.ok) {
parseError = result.reason;
write(null);
return;
}
parseError = null;
if (result.minor === null) {
text = '';
write(null);
return;
}
text = formatAmount(result.minor, target);
write({ amountMinor: result.minor, currency: code });
}
/** Keeps the figures the person entered and never converts them. */
function setCurrency(code: string, announce = true) {
selected = code;
commit(code);
if (!announce) return;
changed = true;
announcement = fill(m.changed, {
...vars,
currency: currencyFormat(locale, code, adornment).name,
code
});
}
/* Typing a character that can never be part of an amount does nothing. A decimal separator
always types, even for yen: dropping it would turn 3000.50 into 300050, so a fraction is
refused with a message on blur instead. Pasted text is read leniently on blur. */
function guard(event: InputEvent) {
if (event.inputType !== 'insertText' || !event.data) return;
if (!/^[\p{Nd}\s.,'’\u066b\u066c]+$/u.test(event.data)) event.preventDefault();
}
function onkeydown(event: KeyboardEvent) {
/* Enter submits the form before blur, so format and write the hidden fields first. */
if (event.key === 'Enter' && !event.isComposing) flushSync(() => commit());
}
/* A press on the mark or the border focuses the input, as a press inside one box should. */
function focusInput(event: PointerEvent) {
if (!input || event.button !== 0 || event.target === input || disabled) return;
if ((event.target as Element).closest('select')) return;
event.preventDefault();
input.focus();
}
/* The input's padding; the fitted sizer repeats it so its hidden copy measures the same box. */
const padClass = $derived([
showMark && format.position === 'before' ? 'ps-1' : size === 'lg' ? 'ps-4' : 'ps-3',
suffix ? 'pe-1' : size === 'lg' ? 'pe-4' : 'pe-3'
]);
const markClass = $derived([
'currency-amount-field__mark flex shrink-0 items-center whitespace-nowrap text-(--_muted) tabular-nums select-none',
adornment === 'code'
? 'text-[13px] leading-5 font-medium tracking-[0.02em] pointer-coarse:text-sm'
: size === 'lg'
? 'text-2xl leading-8'
: 'text-sm leading-5 pointer-coarse:text-base'
]);
</script>
<div
class={['currency-amount-field min-w-0', className]}
data-size={size}
data-invalid={invalid ? '' : undefined}
>
<div class={['grid', hasSelect ? 'grid-cols-[minmax(0,1fr)_auto]' : 'grid-cols-1']}>
<div class={['min-w-0', hasSelect && 'pe-3']}>
<label
for={inputId}
class="block text-sm leading-5 font-medium text-pretty break-words text-(--_ink)"
>
{label}<span class="sr-only">{` ${fill(m.currencyName, vars)}`}</span>
</label>
{#if hint || boundsText}
<p id={hintId} class="mt-1 text-[13px] leading-5 text-pretty break-words text-(--_muted)">
{hint ?? ''}{hint && boundsText ? ' ' : ''}<span class="tabular-nums">{boundsText}</span>
</p>
{/if}
</div>
{#if hasSelect}
<label
for={selectId}
class="currency-amount-field__select-label self-start text-sm leading-5 font-medium text-(--_ink)"
>
{currencyLabel}
</label>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={[
'currency-amount-field__control col-span-full mt-2 grid min-h-(--_h) grid-cols-subgrid overflow-hidden border',
'border-(--_edge) bg-(--_surface) text-(--_ink)',
'rounded-lg'
]}
onpointerdown={focusInput}
>
<div class="flex min-w-0 items-stretch">
{#if showMark && format.position === 'before'}
<span
class={[markClass, size === 'lg' ? 'ps-4' : 'ps-3', adornment === 'code' && 'pe-1']}
aria-hidden="true">{format.mark}</span
>
{/if}
<!-- With the mark after the figures, the input sizes to its text (a hidden copy of it
in the same grid cell sets the width), so the sign follows the last digit instead of
waiting at the far edge. With the mark before, the input simply fills the row. -->
<span
class={[
'currency-amount-field__sizer relative grid min-w-0 tabular-nums',
suffix ? 'currency-amount-field__sizer--fit' : 'flex-1',
size === 'lg'
? 'text-2xl leading-8 font-medium tracking-[-0.02em] rtl:tracking-normal'
: 'text-sm leading-5 pointer-coarse:text-base',
suffix && padClass
]}
data-value={text || shownPlaceholder}
>
<input
bind:this={input}
{...rest}
bind:value={text}
type="text"
inputmode="decimal"
id={inputId}
placeholder={shownPlaceholder}
{required}
{disabled}
{readonly}
aria-describedby={describedBy}
aria-invalid={invalid ? 'true' : undefined}
onbeforeinput={guard}
oninput={() => (changed = false)}
onblur={() => commit()}
{onkeydown}
class={[
'currency-amount-field__input w-full min-w-0 bg-transparent text-(--_ink) tabular-nums outline-none',
'placeholder:text-(--_placeholder) disabled:cursor-not-allowed',
/* Placed in no grid cell, so it covers the sizer's padding box like its hidden copy. */
suffix ? 'absolute inset-0' : 'col-start-1 row-start-1',
padClass
]}
/>
</span>
{#if suffix}
<span
class={[markClass, size === 'lg' ? 'pe-4' : 'pe-3', adornment === 'code' && 'ps-1']}
aria-hidden="true">{format.mark}</span
>
{/if}
</div>
{#if hasSelect}
<div class="currency-amount-field__segment relative grid">
<select
bind:this={select}
id={selectId}
form={rest.form}
name={readonly ? undefined : currencyName}
disabled={disabled || readonly}
value={selected}
onchange={(event) => setCurrency(event.currentTarget.value)}
class={[
'col-start-1 row-start-1 min-h-full cursor-pointer appearance-none bg-transparent ps-3 pe-9 text-(--_ink) outline-none',
'font-medium tabular-nums disabled:cursor-not-allowed',
size === 'lg' ? 'text-base' : 'text-sm pointer-coarse:text-base'
]}
>
{#each currencies ?? [] as code (code)}
<option value={code.toUpperCase()}>{code.toUpperCase()}</option>
{/each}
</select>
<svg
class="pointer-events-none col-start-1 row-start-1 me-3 size-4 self-center justify-self-end text-(--_muted)"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="m4.5 6.25 3.5 3.5 3.5-3.5"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
{/if}
</div>
<div class="col-span-full min-w-0">
{#if invalid}
<p
id={messageId}
class="mt-1 flex items-start gap-1 text-[13px] leading-5 text-(--_danger)"
>
<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
<path
d="M8 4.75v3.5"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
<circle cx="8" cy="11" r="1" fill="currentColor" />
</svg>
</span>
<span class="min-w-0 text-pretty break-words"
><span class="sr-only">{`${errorPrefix} `}</span>{shownError}</span
>
</p>
{:else if changed}
<p id={messageId} class="mt-1 flex items-start gap-1 text-[13px] leading-5 text-(--_muted)">
<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
<path
d="M8 7.25v4"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
<circle cx="8" cy="4.75" r="1" fill="currentColor" />
</svg>
</span>
<span class="min-w-0 text-pretty break-words">{announcement}</span>
</p>
{:else if reserve}
<div class="mt-1 h-5" aria-hidden="true"></div>
{/if}
</div>
</div>
{#if hasSelect}
<p class="sr-only" role="status">{announcement}</p>
{/if}
<input
type="hidden"
name={amountName}
value={value?.amountMinor ?? ''}
form={rest.form}
{disabled}
/>
{#if !hasSelect || readonly}
<input type="hidden" name={currencyName} value={selected} form={rest.form} {disabled} />
{/if}
</div>
<style>
/* Public tokens: set --currency-amount-field-* on the field or any ancestor to retone it. */
.currency-amount-field {
--_accent: var(--currency-amount-field-accent, #18181b);
--_danger: var(--currency-amount-field-danger, #b91c1c);
--_ink: var(--currency-amount-field-ink, #18181b);
--_muted: var(--currency-amount-field-muted, #52525b);
--_placeholder: var(--currency-amount-field-placeholder, #71717a);
/* 3:1 against white: the edge is the only thing marking an empty field (WCAG 1.4.11). */
--_hairline: var(--currency-amount-field-hairline, rgb(0 0 0 / 0.44));
--_surface: var(--currency-amount-field-surface, #ffffff);
--_h: 2.25rem;
--_edge: var(--_hairline);
--_ring: var(--_accent);
--_raised: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
}
.currency-amount-field[data-size='lg'] {
--_h: 3.5rem;
}
@media (pointer: coarse) {
/* 46 px outside, so the input and the select are each 44 px inside the border. */
.currency-amount-field[data-size='md'] {
--_h: 2.875rem;
}
}
/* Invalid: the edge and the focus ring both carry the error colour. */
.currency-amount-field[data-invalid] {
--_edge: var(--_danger);
--_ring: var(--_danger);
}
/* The edge, fill and halo change through the rules below, so their transition lives here. */
.currency-amount-field__control {
transition-property: border-color, box-shadow, background-color;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
.currency-amount-field__segment {
background-color: var(--_raised);
border-inline-start: 1px solid color-mix(in oklab, var(--_hairline) 50%, transparent);
transition: background-color 150ms cubic-bezier(0.2, 0, 0, 1);
}
@media (prefers-reduced-motion: reduce) {
.currency-amount-field__control,
.currency-amount-field__segment {
transition: none;
}
}
/* Hover only where typing or choosing is possible: one step darker edge, or fill on the segment. */
@media (hover: hover) {
.currency-amount-field__control:hover:not(:has(input:disabled, input[readonly])) {
--_edge: color-mix(in oklab, var(--_hairline), var(--_ink) 25%);
}
.currency-amount-field[data-invalid] .currency-amount-field__control:hover {
--_edge: var(--_danger);
}
.currency-amount-field__segment:has(select:enabled:hover) {
background-color: color-mix(in oklab, var(--_surface), var(--_ink) 8%);
}
}
/* Focus anywhere in the shape: a two-pixel edge in the accent, with a soft halo outside. */
.currency-amount-field__control:has(:focus-visible) {
border-color: var(--_ring);
box-shadow:
0 0 0 1px var(--_ring),
0 0 0 4px color-mix(in oklab, var(--_ring) 14%, transparent);
}
/* The select marks itself as the focused part with a tinted fill. */
.currency-amount-field__segment:has(select:focus-visible) {
background-color: color-mix(in oklab, var(--_surface), var(--_accent) 10%);
}
/* Read-only: a raised fill, full-strength text, still focusable and selectable. */
.currency-amount-field__control:has(input[readonly]) {
background-color: var(--_raised);
}
/* Disabled: raised fill, faded, and not interactive. */
.currency-amount-field__control:has(input:disabled) {
background-color: var(--_raised);
opacity: 0.55;
cursor: not-allowed;
}
/*
* The fitted sizer: a hidden copy of the text shares the input's grid cell, so the cell is as
* wide as the figures and the mark after it sits beside the last digit. The copy is a pseudo
* element because it carries no meaning and has more than two properties.
*/
.currency-amount-field__sizer--fit {
flex: 0 1 auto;
}
.currency-amount-field__sizer--fit::after {
content: attr(data-value);
grid-area: 1 / 1;
/* Room for the caret after the last digit. */
padding-inline-end: 2px;
visibility: hidden;
white-space: pre;
overflow: hidden;
}
/* Autofill paints the input alone; keep it transparent and tint the whole shape instead. */
.currency-amount-field__input:autofill {
-webkit-text-fill-color: var(--_ink);
transition: background-color 0s 86400s;
}
.currency-amount-field__control:has(input:autofill) {
background-color: color-mix(in oklab, var(--_surface), var(--_accent) 6%);
}
/* Forced colours drop shadows and repaint borders, so focus becomes a system-colour outline. */
@media (forced-colors: active) {
.currency-amount-field__control:has(:focus-visible) {
outline: 2px solid Highlight;
outline-offset: 2px;
}
}
</style>
Usage#
On this pageGive it a label and a currency and bind value; it reads what the person types into integer minor units with the currency's own decimal places and submits amount_minor and currency as hidden fields. It does not convert between currencies, take payments, collect card details or calculate tax.
- Suggested location
src/lib/components/currency-amount-field-01
Limitations
- Amounts are zero or positive. Negative amounts (refunds, credits) are refused as unreadable.
- Amounts are JavaScript numbers, so the largest is Number.MAX_SAFE_INTEGER minor units (about 90 trillion pounds); past it the field asks for a smaller amount.
minMinorandmaxMinorare in minor units of whichever currency is selected. When the select offers currencies with different decimal places, pass bounds that follow the selected currency.- Changing currency keeps the figures the person entered. Between currencies with the same decimal places (GBP, USD, EUR)
amountMinoris unchanged; from GBP to JPY, 25.00 becomes 25 yen. Nothing is converted. - The hidden fields update on blur, on Enter and on a currency change, so the form needs JavaScript to submit what was typed. The server-rendered value submits before hydration.
- locale defaults to en-US rather than the browser's, so the server and the browser render the same text. Pass the page's locale.
Example
<script lang="ts">
import CurrencyAmountField, {
type CurrencyAmount
} from '$lib/components/currency-amount-field-01/CurrencyAmountField.svelte';
let gift = $state<CurrencyAmount | null>({ amountMinor: 2500, currency: 'GBP' });
</script>
<form method="POST" class="max-w-sm">
<CurrencyAmountField
label="Your donation"
currency="GBP"
locale="en-GB"
size="lg"
minMinor={500}
hint="Gift Aid adds 25p to every £1 if you pay UK tax."
bind:value={gift}
/>
</form>The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Currency amount field#
A money field that keeps the stored value apart from what the person sees. The value is an integer in the currency's minor unit plus its ISO 4217 code:
{ amountMinor: 1250, currency: 'GBP' } // £12.50
{ amountMinor: 1500, currency: 'JPY' } // ¥1,500, yen has no minor unit<CurrencyAmountField
label="Your donation"
currency="GBP"
locale="en-GB"
size="lg"
minMinor={500}
bind:value={gift}
/>What it reads#
Text is read as digit strings, never through a float, so 19.99 stores 1999. The mark, the code
and spaces are ignored, so pasted £1,234.50, GBP 12 and 1.234,56 € all read. The locale's
decimal separator always means decimals; the other separator means decimals only when it appears
once with one or two digits after it (12,5 in en-GB is £12.50, 1,250 is £1,250.00). Grouping
is added when the person leaves the field, never while they type.
Decimal places come from Intl.NumberFormat(...).resolvedOptions().maximumFractionDigits: two for
GBP, none for JPY, three for KWD. For a currency with none, typing a decimal separator does
nothing, and pasted fractions get an error. Trailing zeros are fine (1500.00 yen is 1500).
Submitting#
The visible input has no name. The value submits as two hidden fields, amount_minor and
currency (rename with amountName and currencyName), which update on blur, on Enter and on
a currency change. With a select, the select itself carries currency.
The field's own errors (unreadable text, too many decimals, below minMinor, above maxMinor)
set a custom validity message on the input, so a native submit stops on them. Your own error
replaces the message but does not block submission; do that in your submit handler.
Every editable field holds one 20 px line under the border for its messages, so an error or the
currency note appears without pushing the rest of the form down. Pass reserveMessage={false} to
drop it in a dense table.
Choosing a currency#
currencies adds a select as the field's last segment, inside the same border, with its own
visible label above it. Changing it keeps the figures and never converts them; a note under the
field says so and a polite status region announces it. Between currencies with the same decimal
places amountMinor does not change. From GBP to JPY, 25.00 becomes ¥25.
Bounds#
minMinor and maxMinor are in minor units and appear after the hint as money in the field's
locale: "Minimum £5.00.", "Maximum $2,400.00.", "£5.00 to £10,000.00.".
Other languages#
Pass locale and translate the sentences the field writes with messages:
<CurrencyAmountField
label="Rechnungsbetrag"
currency="EUR"
locale="de-DE"
errorPrefix="Fehler:"
messages={{ invalid: 'Geben Sie einen Betrag in Ziffern ein, zum Beispiel {example}.' }}
/>{currency} is the currency's name from Intl.DisplayNames in that locale, {code} the ISO
code, {min}, {max} and {example} formatted amounts, {digits} the decimal places.
Retoning#
On a #09090b band:
.band {
background: #09090b;
color-scheme: dark;
--currency-amount-field-accent: #fafafa;
--currency-amount-field-ink: #fafafa;
--currency-amount-field-muted: #a1a1aa;
--currency-amount-field-placeholder: #a1a1aa;
--currency-amount-field-hairline: rgb(255 255 255 / 0.35);
--currency-amount-field-surface: #18181b;
--currency-amount-field-danger: #f87171;
}The parsing helper#
money.ts holds the parsing and formatting (currencyFormat, parseAmount, formatAmount,
formatMoney) with no Svelte in it, so a number or percentage field can reuse it.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | Visible label. The currency's name in words is added after it for assistive technology. |
value | CurrencyAmount | null | No | null | Bindable { amountMinor, currency }, for example { amountMinor: 1250, currency: 'GBP' } for £12.50. Null when the field is empty or what was typed cannot be read. |
currency | string | Yes | None | ISO 4217 code. The field's currency, or the first selection when currencies is set and value has none. |
currencies | string[] | No | None | Shows a currency select, attached as the field's last segment, limited to these codes. |
locale | string | No | 'en-US' | Locale for separators, the mark and which side it sits on, and the currency's name. |
minMinor | number | No | None | Smallest allowed amount in minor units. Shown in the hint as money; below it the field shows an error and blocks submission. |
maxMinor | number | No | None | Largest allowed amount in minor units, shown and enforced the same way. |
error | string | null | No | null | Validation message from your code. Shown in place of the field's own messages, with an icon, the danger edge and aria-invalid. |
onChange | (value: CurrencyAmount | null) => void | No | None | Called with the new value when the person leaves the field, presses Enter or changes the currency, only when the value changed. |
hint | string | No | None | Help text under the label. The bounds follow it in the same line. |
adornment | 'symbol' | 'code' | No | 'symbol' | 'symbol' shows the locale's sign (£, US$, €); 'code' shows GBP, USD, EUR. With a select in code mode the select carries the code and no mark is shown. |
size | 'md' | 'lg' | No | 'md' | md is an app field, 36 px with 14 px figures (46 px and 16 px on touch screens, so the input and select are each 44 px inside the border); lg is donation-style entry, 56 px with 24 px figures. |
required | boolean | No | false | Native required on the visible field, so an empty field blocks submission. |
disabled | boolean | No | false | Disables the field and the select; nothing submits. |
readonly | boolean | No | false | Read-only: focusable and selectable, the select is locked, and the value still submits. |
placeholder | string | No | None | Placeholder. Defaults to zero in the currency's format: 0.00, 0 or 0,00. |
amountName | string | No | 'amount_minor' | Name of the hidden field carrying amountMinor. |
currencyName | string | No | 'currency' | Name of the hidden field, or of the select, carrying the currency code. |
currencyLabel | string | No | 'Currency' | Visible label of the currency select. |
errorPrefix | string | No | 'Error:' | Visually hidden text read before an error. |
messages | Partial<CurrencyAmountMessages> | No | None | Replaces any sentence the field writes: currencyName, min, max, range, invalid, decimals, wholeNumber, tooLarge, belowMin, aboveMax, changed. Placeholders: {currency}, {code}, {min}, {max}, {example}, {digits}. |
reserveMessage | boolean | No | None | Hold one line under the field for messages, so one appearing moves nothing below. On by default for every editable field, since any of them can raise a message of its own; set false to drop the line in dense layouts. |
id | string | No | None | Input id. Defaults to one generated per instance; the hint, select and message ids are always generated. |
class | string | No | None | Extra classes on the outer wrapper, for placement. |
Customization#
On this pageChange the copy through props and messages, and retone the field through seven --currency-amount-field-* CSS variables. Hover, the select segment and the read-only fill are mixed from those, so they follow any retone.
- Accent:
--currency-amount-field-accentcolours the focus edge and halo and tints the focused select. Keep it at 3:1 against the page. - Danger:
--currency-amount-field-dangercolours the error edge, ring, icon and message. The default isred-700(6.5:1 on white). - Neutrals:
--currency-amount-field-inkis the label and figures, -muted the hint, mark and notes, -placeholder the zero placeholder, -hairline the resting edge (keep it at 3:1, it is all that marks an empty field) and -surface the fill. - Worked retone for a
#09090bband:--currency-amount-field-accent:#fafafa; -ink:#fafafa; -muted:#a1a1aa; -placeholder:#a1a1aa; -hairline: rgb(255 255 255 / 0.35); -surface:#18181b; -danger:#f87171. Add color-scheme: dark on the band so the select's list is dark too. - Cream page such as a bakery's
#fbf7f0: leave the hairline alone (it is alpha black) and set--currency-amount-field-surface:#fffdf9. - Sizes: md for settings, invoices and admin forms; lg for a donation, tip or pay-what-you-want amount that is the point of the page.
- Mark: symbol for a single-currency page; code where several currencies sit side by side or the symbol is ambiguous ($ for USD, CAD and AUD).
- Language: pass locale and translate
currencyLabel,errorPrefixand messages together.{currency}is the currency's name from Intl.DisplayNames in that locale. - Layout: the field fills its container. Stack fields 20 to 24 px apart and pass class for placement.
Public CSS variables
| Variable | Token |
|---|---|
--currency-amount-field-accent | accent |
--currency-amount-field-danger | danger |
--currency-amount-field-ink | ink |
--currency-amount-field-muted | muted |
--currency-amount-field-placeholder | placeholder |
--currency-amount-field-hairline | hairline |
--currency-amount-field-surface | surface |
Accessibility#
On this page- A native <label for> names the input, followed by the currency's name in words in a visually hidden
span: 'Your donation (British Pound)'. The mark inside the border isaria-hidden. - The input is
type=textwith inputmode decimal, nevertype=number, so a scroll wheel cannot change an amount and grouping separators can be typed or pasted. aria-describedbylists the hint and bounds, then the current error or currency note, then anyaria-describedbyyou pass.aria-invalidis set while an error shows.- The currency select has its own visible label. A change is announced through a polite status region and shown as a note under the field.
- Errors carry an icon, text and a visually hidden
errorPrefix, never colour alone. They are not live regions; move focus to the field or an error summary when a submit fails. - The field's own errors (unreadable, too many decimals, out of bounds) set a custom validity message, so native submission stops on them as it would on required.
- Translate messages,
currencyLabelanderrorPrefixwith the page; the currency name follows locale automatically.
Known limitations
- Screen readers read the select's options as codes (GBP, USD). Use codes your audience knows, or translate the label to say what they mean.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.