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; there is nothing else.
---
// src/components/ui/forms/phone-input/CountrySelect.astro — PhoneInput compound part (see ../../README.md).
// The dial-code picker, as a native <select>. Native because a country list is long: a <select> gets
// the platform's own scrolling, type-ahead ("g", "e", "r" jumps to Germany) and, on a phone, the
// OS's full-screen picker — none of which a custom listbox reproduces for free.
//
// THE LIBRARY SHIPS NO COUNTRY DATA, on purpose. A list of 250 countries is data, it is opinionated
// (names, order, which territories appear), it needs translating, and it would be dead weight for
// the consumer who supports three countries. So you pass `countries` — and with each one, optionally,
// the two facts this library will not guess: how to group the digits, and whether there is a trunk
// prefix to drop (see phone.ts on why guessing that would corrupt Italian numbers).
import type { HTMLAttributes } from "astro/types";
interface Country {
/** ISO code, used as the option's value: `GB`. */
code: string;
/** What to show: "United Kingdom (+44)". */
label: string;
/** Dial code, digits only or with a `+`: `44`. */
dial: string;
/** Grouping for the national number, `#` per digit: `"#### ### ####"`. */
format?: string;
/** A national-only prefix to drop when composing E.164: the UK's leading `0`. */
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>
---
// src/components/ui/forms/phone-input/PhoneInput.astro — headless primitive (see ../../README.md).
// A country picker beside a real <input type="tel">, with a hidden input carrying E.164 — the
// `+442071234567` form every telephony API and every database wants, composed from two things a
// user should never have to think about at once.
//
// The visible field shows the national number, grouped as the chosen country says to group it (the
// pattern comes from the country data you pass to CountrySelect — this library ships no phone number
// database; see phone.ts for why, and what it does instead).
//
// Typing is REFORMATTED as it goes, which means moving the caret. The script only rewrites the field
// when the text actually changed and parks the caret at the end, which is right for typing and for
// pasting, and wrong for editing the middle of a number — a caret-preserving reformatter needs to map
// positions through the pattern.
//
// ponytail: no validation of length or plausibility. `<input type="tel">` deliberately has no
// built-in validation either, because there is no pattern that is right everywhere; add `pattern` or
// `minlength` per country if your form needs it.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"input"> & {
/** The national number to start with, as typed. */
value?: string;
/** The hidden input's name — the one that carries the E.164 value. */
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);
// Only write when it changed: assigning `.value` moves the caret to the end, so doing it on
// every keystroke regardless would fight the user even when nothing was reformatted.
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());
// Changing country re-groups the number that is already there — the same digits, the new shape.
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;
// src/components/ui/forms/phone-input/phone.ts — digits in, E.164 out, plus the display grouping. Pure, so
// it is checkable without a DOM (see phone.test.ts).
//
// WHAT THIS DELIBERATELY IS NOT: a phone number library. Knowing that a London number is
// `020 7123 4567` but a Milan one keeps its leading zero takes a data table, and the smallest honest
// one (libphonenumber) is bigger than this entire library. So the knowledge is DATA the consumer
// supplies per country — a grouping pattern, and whether there is a trunk prefix to drop — and this
// module applies it. Supply nothing and nothing is guessed: the digits are passed through, and the
// submitted value is the dial code followed by what was typed.
/** Every digit in `text`, in order, and nothing else. */
export function digitsOf(text: string): string {
return text.replace(/\D/g, "");
}
/**
* Lay `digits` out along a grouping pattern, `#` standing for a digit — `"### ### ####"`.
*
* Literals appear only once there is a digit to follow them, so the field never shows a dangling
* space or bracket ahead of the typing. With no pattern the digits come back untouched.
*
* @example applyPattern("2071234", "### ### ####") // => "207 123 4"
*/
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;
}
}
// More digits than the pattern accounts for are kept rather than swallowed: a pattern that is
// wrong for this number must not silently truncate someone's phone number.
return out + digits.slice(i);
}
/**
* The value a form should submit: `+` then the dial code then the national digits, with no spaces —
* which is what E.164 is, and what every telephony API expects.
*
* @param trunkPrefix - a national-only prefix to drop (the UK's leading `0`). Supplied per country;
* never guessed, because Italy keeps its leading zero and a guess would corrupt the number.
* @example toE164("44", "020 7123 4567", "0") // => "+442071234567"
*/
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.