For the complete documentation index, see 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.
1
Support

Input Phone

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

Playground

Live preview
Normalized value (digits without +): empty
The displayed national draft is normalized to international digits without a leading plus sign.
Customize input-phone
Customize
input-phone-demo.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

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

$ pnpm dlx 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

Installed automatically by the registry CLI. For manual setup:

Dependencies3 required packages
  • cn
  • libphonenumber-js
  • lucide-react
$ pnpm add cn libphonenumber-js lucide-react

Usage

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

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

International digits (without +): empty
input-phone-demo.tsx
"use client";

import { useState } from "react";

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

export const InputPhoneDemo = () => {
  const [phone, setPhone] = useState<string>();
  return (
    <div className="grid max-w-sm gap-2">
      <InputPhone
        label="Phone number"
        defaultCountry="ID"
        value={phone}
        onValueChange={setPhone}
      />
      <output className="text-sm text-muted-foreground">
        International digits (without +): {phone ?? "empty"}
      </output>
    </div>
  );
};

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

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

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
labelStyle"floating" | "static"

InputPhone: label presentation for the number field.

"floating"
countrySelectLabelstring

InputPhone: accessible name of country selector.

"Country calling code"
defaultCountryCountryCode

InputPhone: initial country calling code.

"ID"
defaultValuestring | undefined

InputPhone: initial international digits without a plus sign.

undefined
disabledboolean

InputPhone: disables number and country selector.

false
labelstring

InputPhone: visible label for the number field.

undefined
localestring

InputPhone: locale for country display names.

"en"
namenot supported

InputPhone intentionally omits native name; serialize the normalized value in the form owner.

not forwarded
onValueChange(value: string | undefined) => void

InputPhone: emits international digits without plus, or undefined.

undefined
readOnlyboolean

InputPhone: prevents number/country edits while preserving focus.

false
valuestring | undefined

InputPhone: international digits without a plus sign.

uncontrolled

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

"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>
    );
  }
);