Currency Input
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add forms/currency-inputPlain-CSS theme — no build step
npx astrocraft-ui add forms/currency-input --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/currency-input --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/currency-input --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/currency-input --theme tailwind --bridge lumosLive demo
PhoneInput & CurrencyInput
The phone field groups digits the way the chosen country's data says to, and submits E.164 from a hidden input; switch country and the same digits regroup. The money field formats on BLUR, not while you type — a field that regroups mid-keystroke moves the caret out from under you — and reads both 1,234.56 and1.234,56 correctly, whichparseFloat does not.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `CurrencyInput.astro` | `currency-input` `currency-input-field` `currency-input-value` | `data-size`: `sm` · `md` · `lg` `data-state`: `default` · `error` · `success` | `data-value` | — |
Source
What the command copies — 3 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/forms/currency-input/CurrencyInput.astro — headless primitive (see ../../README.md).
// A money field: type freely, and on blur it formats to the locale's own notation while a hidden
// input carries the plain number the server should receive.
//
// FORMAT ON BLUR, NOT ON EVERY KEYSTROKE. A field that regroups while you type moves the caret out
// from under you — type "1234" and the separator lands mid-number, and the next digit goes in the
// wrong place. Waiting until focus leaves is both simpler and better, and it is why this needs no
// caret arithmetic at all.
//
// It is `type="text"` with `inputmode="decimal"`, not `type="number"`: a number input refuses to
// hold "1,234.56", silently empties itself on an unparseable keystroke in some browsers, and comes
// with spinners nobody wants on a price. The value that submits is the hidden input's.
//
// The reading and writing is `Intl` in `amount.ts` — including the part that catches people out,
// which is that "1.234,56" and "1,234.56" are the same amount and `parseFloat` gets one of them
// wrong. That module is checked by `amount.test.ts`.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"input"> & {
/** The amount to start with, as a plain number. */
value?: number;
/** The hidden input's name — the one carrying the plain number. */
name?: string;
/** ISO 4217 code. Omit it to format as a plain grouped number, with your own symbol beside it. */
currency?: string;
locale?: string;
label?: string;
size?: "sm" | "md" | "lg";
state?: "default" | "error" | "success";
};
const {
value,
name,
currency,
locale,
label = "Amount",
size = "md",
state = "default",
class: className,
...rest
} = Astro.props;
const formatted =
value === undefined
? ""
: new Intl.NumberFormat(
locale,
currency
? { style: "currency", currency }
: { minimumFractionDigits: 2, maximumFractionDigits: 2 },
).format(value);
---
<div data-slot="currency-input" data-value={value} data-locale={locale} data-currency={currency}>
<input
type="text"
inputmode="decimal"
autocomplete="off"
value={formatted}
aria-label={label}
data-slot="currency-input-field"
data-size={size}
data-state={state}
class={className}
{...rest}
/>
{name && <input type="hidden" name={name} value={value} data-slot="currency-input-value" />}
</div>
<script>
import { onReadyOnce } from "../../_once";
import { formatAmount, parseAmount } from "./amount";
function wire(root: HTMLElement) {
const field = root.querySelector<HTMLInputElement>('[data-slot="currency-input-field"]');
if (!field) return;
const hidden = root.querySelector<HTMLInputElement>('[data-slot="currency-input-value"]');
const locale = root.dataset.locale || undefined;
const currency = root.dataset.currency || null;
const publish = (amount: number | null) => {
const plain = amount === null ? "" : String(amount);
root.dataset.value = plain;
if (hidden) hidden.value = plain;
};
field.addEventListener("blur", () => {
const amount = parseAmount(field.value, locale);
// Nothing parseable: leave what they typed alone and publish nothing. Replacing it with "0.00"
// would invent a price, and clearing it would throw away the correction they were making.
if (amount === null) {
publish(null);
return;
}
field.value = formatAmount(amount, locale, currency);
publish(amount);
field.dispatchEvent(new Event("change", { bubbles: true }));
});
// Focusing gives back the bare number, so the first keystroke is not fighting a currency symbol
// and a group separator that the browser would otherwise have to be persuaded to accept.
field.addEventListener("focus", () => {
const amount = parseAmount(field.value, locale);
if (amount !== null) field.value = String(amount);
field.select();
});
publish(parseAmount(field.value, locale));
}
onReadyOnce('[data-slot="currency-input"]', wire);
</script>
// src/components/ui/forms/currency-input/amount.ts — reading and writing a money amount in the user's own
// notation. Pure, so it is checkable without a DOM (see amount.test.ts).
//
// THE PROBLEM THIS SOLVES: `Number("1.234,56")` is `NaN`, and `Number("1,234.56")` is 1. Those are
// the same amount written by a German and an American, and a field that ran `parseFloat` on either
// one has already lost — silently, as a thousand-times-too-small number. The separators are not
// guessable from the language either (Swiss German uses an apostrophe), so they are ASKED OF `Intl`
// with `formatToParts`, which is the same source that will format the value back out.
export interface Separators {
readonly group: string;
readonly decimal: string;
}
/** What `locale` puts between thousands and before the decimals. */
export function separatorsFor(locale?: string): Separators {
const parts = new Intl.NumberFormat(locale).formatToParts(12345.6);
return {
group: parts.find((part) => part.type === "group")?.value ?? ",",
decimal: parts.find((part) => part.type === "decimal")?.value ?? ".",
};
}
/**
* Read what someone typed as a number — their separators, their currency symbol, their spaces.
*
* @returns the amount, or `null` when there is no number in the text at all (which is what an empty
* field is, and what must NOT become `0` — a zero is a price, and an empty field is not)
* @example parseAmount("1.234,56 €", "de-DE") // => 1234.56
*/
export function parseAmount(text: string, locale?: string): number | null {
const { group, decimal } = separatorsFor(locale);
const cleaned = text
.split(group)
.join("")
// Non-breaking and narrow spaces are what `Intl` itself groups with in several locales, so they
// arrive back in the field on the next edit and have to be accepted.
.replace(/[\s\u00a0\u202f]/g, "")
.replace(decimal, ".")
.replace(/[^0-9.-]/g, "");
if (!/\d/.test(cleaned)) return null;
const value = Number(cleaned);
return Number.isFinite(value) ? value : null;
}
/**
* Write an amount the way `locale` writes money. With no `currency` it is a plain grouped number,
* which is the right thing for a field whose symbol is drawn beside it by an InputGroup addon.
*
* @example formatAmount(1234.5, "en-GB", "GBP") // => "£1,234.50"
*/
export function formatAmount(value: number, locale?: string, currency?: string | null): string {
return new Intl.NumberFormat(
locale,
currency
? { style: "currency", currency }
: { minimumFractionDigits: 2, maximumFractionDigits: 2 },
).format(value);
}
import CurrencyInput from "./CurrencyInput.astro";
export { CurrencyInput };
export default CurrencyInput;
What you get
The component source, copied into your project by npx astrocraft-ui add forms/currency-input — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.