Skip to main content
astrocraft-ui/ components · 101

Currency Input

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add forms/currency-input

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/currency-input --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/currency-input --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/currency-input --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/currency-input --theme tailwind --bridge lumos

Live 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.

Phone

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.

ComponentSlotsVariantsRuntime stateNative 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.

src/components/ui/forms/currency-input/CurrencyInput.astro
---
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>
src/components/ui/forms/currency-input/amount.ts
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);
}
src/components/ui/forms/currency-input/index.ts
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.