# Input Phone

An international phone input with a localized country calling-code selector.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. An agent skill is available at [/.well-known/agent-skills/site-skill.md](/.well-known/agent-skills/site-skill.md).





## Playground [#playground]

```tsx
"use client";

import { useState } from "react";
import { InputPhone } from "@/components/ui/input-phone";

export function InputPhoneDemo() {
  const [phone, setPhone] = useState<string>();
  return (
    <>
      <InputPhone
          label={"Phone number"}
          value={phone}
          onValueChange={setPhone}
          defaultCountry={"ID"}
          locale={"en"}
          labelStyle={"floating"}
      />
    </>
  );
}
```

## Installation [#installation]

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/input-phone.json
```

The default installation target is components/ui/input-phone.tsx. Review the CLI's overwrite prompt before replacing local customizations.

## Dependencies [#dependencies]

Installed automatically by the registry CLI. For manual setup:

```bash
npm install cn libphonenumber-js lucide-react
```

## Usage [#usage]

```tsx
import { InputPhone } from "@/components/ui/input-phone";

<InputPhone
  label="Phone number"
  defaultCountry="ID"
  value={phone}
  onValueChange={setPhone}
/>;
```

The default country is Indonesia. The public value contains international digits without a leading plus sign.

The compact calling-code selector stays beside the number field. Its menu shows localized country names and calling codes. Floating labels rise to a notch in the top border of the compact 40px control; use `labelStyle="static"` for a conventional label.

## Examples [#examples]

The controlled output shows the application value: international digits without `+`.

<ComponentPreview name="input-phone-demo" title="input-phone-demo.tsx">
  <InputPhoneDemo />
</ComponentPreview>

### Optional Storybook Stories [#optional-storybook-stories]

Install Input Phone first, then run `npx shadcn@latest add https://vandor-ui.vercel.app/r/input-phone-stories.json`. Stories cover country selection, localized labels, international digits returned to controlled state, and disabled/read-only/invalid states. Controls remount uncontrolled examples when the initial country or number changes.

Stories install beside Input Phone under your configured UI alias and use relative imports. Use your existing matching `@storybook/react` packages (verified with 10.6.1), discover `**/*.stories.tsx`, and load your global Tailwind/theme stylesheet. No addons, extra packages, core overwrites, or Storybook config edits are required. Controlled owns local state and uses an empty string for an empty controlled field. Storybook UI is not verified because no server exists here.

## Behavior & Accessibility [#behavior--accessibility]

Country names follow `locale`; the calling-code picker has an accessible name. Partial numbers remain editable and preserve the draft. Formatting does not validate that a number exists, is reachable, or satisfies application rules. Empty input emits `undefined`.

## Props [#props]

| Prop               | Type                                 | Default                | Description                                                                                   |
| ------------------ | ------------------------------------ | ---------------------- | --------------------------------------------------------------------------------------------- |
| labelStyle         | "floating" \| "static"               | "floating"             | InputPhone: label presentation for the number field.                                          |
| countrySelectLabel | string                               | "Country calling code" | InputPhone: accessible name of country selector.                                              |
| defaultCountry     | CountryCode                          | "ID"                   | InputPhone: initial country calling code.                                                     |
| defaultValue       | string \| undefined                  | undefined              | InputPhone: initial international digits without a plus sign.                                 |
| disabled           | boolean                              | false                  | InputPhone: disables number and country selector.                                             |
| label              | string                               | undefined              | InputPhone: visible label for the number field.                                               |
| locale             | string                               | "en"                   | InputPhone: locale for country display names.                                                 |
| name               | not supported                        | not forwarded          | InputPhone intentionally omits native name; serialize the normalized value in the form owner. |
| onValueChange      | (value: string \| undefined) => void | undefined              | InputPhone: emits international digits without plus, or undefined.                            |
| readOnly           | boolean                              | false                  | InputPhone: prevents number/country edits while preserving focus.                             |
| value              | string \| undefined                  | uncontrolled           | InputPhone: international digits without a plus sign.                                         |

The reference lists component-specific props and selected native props, not the complete React API. Other native attributes are forwarded to the rendered element.

## Source [#source]

```tsx
"use client";

import { cn } from "cn";
import {
  AsYouType,
  getCountries,
  getCountryCallingCode,
  parseIncompletePhoneNumber,
} from "libphonenumber-js";
import type { CountryCode } from "libphonenumber-js";
import { ChevronDown } from "lucide-react";
import * as React from "react";

import type { InputProps } from "./input";
import { InputGroup, InputGroupAddon, InputGroupInput } from "./input-group";
import { Select } from "./select";

export type InputPhoneProps = Omit<
  InputProps,
  "value" | "defaultValue" | "onChange" | "type" | "icon" | "name"
> & {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string | undefined) => void;
  defaultCountry?: CountryCode;
  locale?: string;
  countrySelectLabel?: string;
};

const countries = getCountries();

const parseDraft = (draft: string, country: CountryCode) => {
  let compact = parseIncompletePhoneNumber(draft);
  if (country === "ID" && !compact.startsWith("+")) {
    compact = compact.replace(/^62/, "").replace(/^0+/, "");
  }
  const formatter = new AsYouType(country);
  formatter.input(compact);
  const callingCode = formatter.getCallingCode();
  const nextCountry = compact.startsWith("+")
    ? (formatter.getCountry() ??
      countries.find((item) => getCountryCallingCode(item) === callingCode) ??
      country)
    : country;
  const prefix = `+${getCountryCallingCode(nextCountry)}`;
  const national =
    formatter.getNumber()?.nationalNumber ??
    (compact.startsWith(prefix) ? compact.slice(prefix.length) : compact);
  const international = new AsYouType();
  const formatted = national ? international.input(`${prefix}${national}`) : "";
  return {
    country: nextCountry,
    draft: formatted.slice(prefix.length).trimStart(),
    value: international.getNumberValue()?.slice(1),
  };
};

const stateFromValue = (value: string | undefined, country: CountryCode) => {
  const normalized =
    value && !value.startsWith("+") && !value.startsWith("0")
      ? `+${value}`
      : value;
  const parsed = parseDraft(normalized ?? "", country);
  return { ...parsed, controlledValue: value };
};

export const InputPhone = React.forwardRef<HTMLInputElement, InputPhoneProps>(
  function InputPhone(
    {
      value,
      defaultValue,
      onValueChange,
      defaultCountry = "ID",
      locale = "en",
      countrySelectLabel = "Country calling code",
      label,
      labelStyle = "floating",
      labelClassName,
      containerClassName,
      className,
      id,
      disabled,
      readOnly,
      ...props
    },
    forwardedRef
  ) {
    const generatedId = React.useId();
    const inputId = id ?? generatedId;
    const inputRef = React.useRef<HTMLInputElement>(null);
    React.useImperativeHandle(
      forwardedRef,
      () => inputRef.current as HTMLInputElement
    );
    const pendingCaret = React.useRef<number | null>(null);
    const [state, setState] = React.useState(() => ({
      ...stateFromValue(value ?? defaultValue, defaultCountry),
      controlledValue: value,
    }));
    if (value !== state.controlledValue) {
      setState(
        value === state.value
          ? { ...state, controlledValue: value }
          : { ...stateFromValue(value, state.country), controlledValue: value }
      );
    }
    React.useLayoutEffect(() => {
      const input = inputRef.current;
      if (
        input &&
        document.activeElement === input &&
        pendingCaret.current !== null
      ) {
        input.setSelectionRange(pendingCaret.current, pendingCaret.current);
      }
      pendingCaret.current = null;
    }, [state]);

    const options = React.useMemo(() => {
      const names = new Intl.DisplayNames([locale], { type: "region" });
      return (
        countries
          .map((country) => ({
            country,
            label: `${names.of(country) ?? country} (+${getCountryCallingCode(country)})`,
          }))
          // ES2022 consumers lack toSorted; map already created a new array.
          // oxlint-disable-next-line unicorn/no-array-sort
          .sort((left, right) => left.label.localeCompare(right.label, locale))
      );
    }, [locale]);

    const update = (draft: string, country: CountryCode) => {
      const parsed = parseDraft(draft, country);
      const caretPosition = inputRef.current?.selectionStart;
      if (caretPosition !== null && caretPosition !== undefined) {
        const digitCount = draft
          .slice(0, caretPosition)
          .replaceAll(/\D/g, "").length;
        let seen = 0;
        let nextCaret = parsed.draft.length;
        for (let index = 0; index < parsed.draft.length; index += 1) {
          if (/\d/.test(parsed.draft.charAt(index))) {
            seen += 1;
          }
          if (seen >= digitCount) {
            nextCaret = index + 1;
            break;
          }
        }
        pendingCaret.current = nextCaret;
      }
      setState({ ...parsed, controlledValue: value });
      onValueChange?.(parsed.value);
    };

    const selectedOption = options.find(
      (option) => option.country === state.country
    );

    return (
      <div
        data-slot="phone-input"
        className={cn("grid min-w-0 gap-1.5", containerClassName)}
      >
        <InputGroup className="flex-nowrap">
          <InputGroupAddon className="self-end p-0">
            <Select
              data={options.map((option) => ({
                label: option.label,
                value: option.country,
              }))}
              value={state.country}
              disabled={disabled || readOnly}
              aria-label={`${countrySelectLabel}: ${selectedOption?.label ?? state.country}`}
              className="flex w-20 shrink-0 items-center justify-center gap-1 self-end rounded-l-md px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed"
              trigger={
                <button
                  type="button"
                  className={
                    label && labelStyle === "floating" ? "h-[38px]" : "h-9"
                  }
                >
                  +{getCountryCallingCode(state.country)}
                  <ChevronDown aria-hidden="true" className="size-4" />
                </button>
              }
              onValueChange={(country) => {
                if (country) {
                  update(state.draft, country);
                }
              }}
            />
          </InputGroupAddon>
          <InputGroupInput
            {...props}
            ref={inputRef}
            id={inputId}
            label={label}
            labelStyle={labelStyle}
            labelClassName={labelClassName}
            type="tel"
            inputMode="tel"
            autoComplete={props.autoComplete ?? "tel-national"}
            value={state.draft}
            disabled={disabled}
            readOnly={readOnly}
            aria-invalid={props["aria-invalid"]}
            onChange={(event) =>
              update(event.currentTarget.value, state.country)
            }
            className={cn("border-l border-input", className)}
          />
        </InputGroup>
      </div>
    );
  }
);

```
