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, shown without its comments; the files you install keep them, with the reasoning behind every rule.
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"input"> & {
value?: number;
name?: string;
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);
if (amount === null) {
publish(null);
return;
}
field.value = formatAmount(amount, locale, currency);
publish(amount);
field.dispatchEvent(new Event("change", { bubbles: true }));
});
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>
export interface Separators {
readonly group: string;
readonly decimal: string;
}
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 ?? ".",
};
}
export function parseAmount(text: string, locale?: string): number | null {
const { group, decimal } = separatorsFor(locale);
const cleaned = text
.split(group)
.join("")
.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;
}
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.