# Phone Input

> Phone input with a country dial-code select, live per-country formatting and E.164 output.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/phone-input
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/phone-input.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/phone-input-demo.tsx"
"use client"

import * as React from "react"

import { PhoneInput, type PhoneValue } from "@/components/phone-input"

export default function PhoneInputDemo() {
  const [phone, setPhone] = React.useState<PhoneValue | null>(null)
  return (
    <div className="grid w-full max-w-sm gap-2">
      <label className="text-sm font-medium">Phone number</label>
      <PhoneInput onValueChange={setPhone} />
      <p className="text-xs text-muted-foreground" data-testid="e164">
        {phone?.national ? `E.164: ${phone.e164}` : "Pick a country and start typing."}
      </p>
    </div>
  )
}
```

## Source

```tsx title="components/phone-input.tsx"
"use client"

import * as React from "react"
import { ChevronDown } from "lucide-react"

import { cn } from "@/lib/utils"

export interface Country {
  code: string
  name: string
  dial: string
  /** "#" is a digit slot, anything else is a literal */
  format: string
}

export const COUNTRIES: Country[] = [
  { code: "US", name: "United States", dial: "+1", format: "(###) ###-####" },
  { code: "GB", name: "United Kingdom", dial: "+44", format: "#### ######" },
  { code: "CN", name: "China", dial: "+86", format: "### #### ####" },
  { code: "JP", name: "Japan", dial: "+81", format: "##-####-####" },
  { code: "DE", name: "Germany", dial: "+49", format: "#### #######" },
  { code: "FR", name: "France", dial: "+33", format: "# ## ## ## ##" },
  { code: "IN", name: "India", dial: "+91", format: "##### #####" },
  { code: "AU", name: "Australia", dial: "+61", format: "### ### ###" },
]

function applyFormat(digits: string, format: string) {
  let out = ""
  let i = 0
  for (const ch of format) {
    if (i >= digits.length) break
    if (ch === "#") out += digits[i++]
    else out += ch
  }
  return out
}

export interface PhoneValue {
  country: string
  national: string
  /** E.164 style, e.g. +14155550123 */
  e164: string
}

export interface PhoneInputProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  defaultCountry?: string
  countries?: Country[]
  onValueChange?: (value: PhoneValue) => void
  invalid?: boolean
}

export function PhoneInput({
  defaultCountry = "US",
  countries = COUNTRIES,
  onValueChange,
  invalid = false,
  className,
  ...props
}: PhoneInputProps) {
  const [code, setCode] = React.useState(defaultCountry)
  const [digits, setDigits] = React.useState("")
  const country = countries.find((c) => c.code === code) ?? countries[0]
  const maxDigits = (country.format.match(/#/g) ?? []).length

  const emit = (nextCode: string, nextDigits: string) => {
    const next = countries.find((c) => c.code === nextCode) ?? country
    onValueChange?.({ country: nextCode, national: nextDigits, e164: `${next.dial}${nextDigits}` })
  }

  return (
    <div
      {...props}
      aria-invalid={invalid || undefined}
      className={cn(
        "flex h-10 w-full items-stretch overflow-hidden rounded-lg border border-input bg-background text-sm transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-4 focus-within:ring-ring/30",
        invalid && "border-destructive focus-within:border-destructive focus-within:ring-destructive/20",
        className,
      )}
    >
      <div className="relative flex shrink-0 items-center border-r bg-muted/40 transition-colors hover:bg-muted">
        <select
          aria-label="Country"
          value={code}
          onChange={(event) => {
            const nextDigits = digits.slice(0, (countries.find((c) => c.code === event.target.value)?.format.match(/#/g) ?? []).length)
            setCode(event.target.value)
            setDigits(nextDigits)
            emit(event.target.value, nextDigits)
          }}
          className="h-full cursor-pointer appearance-none bg-transparent pl-3 pr-8 font-medium outline-none"
        >
          {countries.map((c) => (
            <option key={c.code} value={c.code}>
              {c.code} {c.dial}
            </option>
          ))}
        </select>
        <ChevronDown aria-hidden className="pointer-events-none absolute right-2 size-4 text-muted-foreground" />
      </div>
      <input
        type="tel"
        inputMode="tel"
        autoComplete="tel-national"
        aria-label="Phone number"
        placeholder={country.format.replace(/#/g, "0")}
        value={applyFormat(digits, country.format)}
        onChange={(event) => {
          const next = event.target.value.replace(/\D/g, "").slice(0, maxDigits)
          setDigits(next)
          emit(code, next)
        }}
        className="min-w-0 flex-1 bg-transparent px-3 tabular-nums outline-none placeholder:text-muted-foreground"
      />
    </div>
  )
}
```
