Skip to main content
astrocraft-ui/ components · 101

Phone 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/phone-input

Plain-CSS theme — no build step

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

Tailwind theme — needs Tailwind v4

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

Plain-CSS theme, re-pointed onto Lumos tokens

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

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/phone-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
`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.

src/components/ui/forms/phone-input/CountrySelect.astro
---
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>
src/components/ui/forms/phone-input/PhoneInput.astro
---
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>
src/components/ui/forms/phone-input/index.ts
import CountrySelect from "./CountrySelect.astro";
import PhoneInput from "./PhoneInput.astro";

export { CountrySelect, PhoneInput };
export default PhoneInput;
src/components/ui/forms/phone-input/phone.ts
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.