- Accordion
- AppShell
- Autocomplete
- Badge
- Blobatar
- BoringAvatar
- Button
- Calendar
- Checkbox
- DataGrid
- Date Picker
- Date Range Picker
- DateTime
- Dialog
- Drawer
- Dropdown
- Empty
- Error State
- Frame
- Input
- Input Amount
- Input Group
- Input OTP
- Input Password
- Input Phone
- Input Search
- Input Secret
- Loading
- PageContainer
- PageHeader
- Popover
- Select
- StatsCard
- Switch
- Tabs
- TextArea
- Toast
- Tooltip
Playground
"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:
- cn
- libphonenumber-js
- lucide-react
$ pnpm add cn libphonenumber-js lucide-reactUsage
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 +.
"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
| Prop | Type / Description | Default |
|---|---|---|
| labelStyle | "floating" | "static"InputPhone: label presentation for the number field. | "floating" |
| countrySelectLabel | stringInputPhone: accessible name of country selector. | "Country calling code" |
| defaultCountry | CountryCodeInputPhone: initial country calling code. | "ID" |
| defaultValue | string | undefinedInputPhone: initial international digits without a plus sign. | undefined |
| disabled | booleanInputPhone: disables number and country selector. | false |
| label | stringInputPhone: visible label for the number field. | undefined |
| locale | stringInputPhone: locale for country display names. | "en" |
| name | not supportedInputPhone intentionally omits native name; serialize the normalized value in the form owner. | not forwarded |
| onValueChange | (value: string | undefined) => voidInputPhone: emits international digits without plus, or undefined. | undefined |
| readOnly | booleanInputPhone: prevents number/country edits while preserving focus. | false |
| value | string | undefinedInputPhone: 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>
);
}
);