Phone 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/phone-inputPlain-CSS theme — no build step
npx astrocraft-ui add forms/phone-input --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/phone-input --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/phone-input --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/phone-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 |
|---|---|---|---|---|
| `CountrySelect.astro` | `country-select` | `data-size`: `sm` · `md` · `lg` `data-state`: `default` · `error` · `success` | — | — |
| `PhoneInput.astro` | `phone-input` `phone-input-field` `phone-input-value` | `data-size`: `sm` · `md` · `lg` `data-state`: `default` · `error` · `success` | `data-value` | — |
Source
What the command copies — 4 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";
interface Country {
code: string;
label: string;
dial: string;
format?: string;
trunkPrefix?: string;
}
type Props = HTMLAttributes<"select"> & {
countries: readonly Country[];
value?: string;
label?: string;
size?: "sm" | "md" | "lg";
state?: "default" | "error" | "success";
};
const {
countries,
value,
label = "Country",
size = "md",
state = "default",
class: className,
...rest
} = Astro.props;
---
<select
aria-label={label}
data-slot="country-select"
data-size={size}
data-state={state}
class={className}
{...rest}
>
{
countries.map((country) => (
<option
value={country.code}
selected={country.code === value}
data-dial={country.dial}
data-format={country.format}
data-trunk={country.trunkPrefix}
>
{country.label}
</option>
))
}
</select>
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"input"> & {
value?: string;
name?: string;
label?: string;
placeholder?: string;
size?: "sm" | "md" | "lg";
state?: "default" | "error" | "success";
};
const {
value,
name,
label = "Phone number",
placeholder,
size = "md",
state = "default",
class: className,
...rest
} = Astro.props;
---
<div data-slot="phone-input">
<slot />
<input
type="tel"
inputmode="tel"
autocomplete="tel-national"
value={value}
aria-label={label}
placeholder={placeholder}
data-slot="phone-input-field"
data-size={size}
data-state={state}
class={className}
{...rest}
/>
{name && <input type="hidden" name={name} data-slot="phone-input-value" />}
</div>
<script>
import { onReadyOnce } from "../../_once";
import { applyPattern, digitsOf, toE164 } from "./phone";
function wire(root: HTMLElement) {
const field = root.querySelector<HTMLInputElement>('[data-slot="phone-input-field"]');
if (!field) return;
const select = root.querySelector<HTMLSelectElement>('[data-slot="country-select"]');
const hidden = root.querySelector<HTMLInputElement>('[data-slot="phone-input-value"]');
const country = () => select?.selectedOptions[0]?.dataset ?? {};
const sync = ({ reformat = true } = {}) => {
const { dial = "", format, trunk } = country();
if (reformat) {
const grouped = applyPattern(digitsOf(field.value), format);
if (grouped !== field.value) field.value = grouped;
}
const e164 = toE164(dial, field.value, trunk);
root.dataset.value = e164;
if (hidden) hidden.value = e164;
};
field.addEventListener("input", () => sync());
select?.addEventListener("change", () => sync());
sync({ reformat: Boolean(field.value) });
}
onReadyOnce('[data-slot="phone-input"]', wire);
</script>
import CountrySelect from "./CountrySelect.astro";
import PhoneInput from "./PhoneInput.astro";
export { CountrySelect, PhoneInput };
export default PhoneInput;
export function digitsOf(text: string): string {
return text.replace(/\D/g, "");
}
export function applyPattern(digits: string, pattern?: string | null): string {
if (!pattern) return digits;
let out = "";
let i = 0;
for (const char of pattern) {
if (i >= digits.length) break;
if (char === "#") {
out += digits[i];
i += 1;
} else {
out += char;
}
}
return out + digits.slice(i);
}
export function toE164(dial: string, national: string, trunkPrefix?: string | null): string {
const code = digitsOf(dial);
let digits = digitsOf(national);
if (trunkPrefix && digits.startsWith(trunkPrefix)) digits = digits.slice(trunkPrefix.length);
return digits ? `+${code}${digits}` : "";
}
What you get
The component source, copied into your project by npx astrocraft-ui add forms/phone-input — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.