Componentpickers1.6 KB gzipZero dependencies

PhoneInput

Phone number input with country code selector and formatted display

Preview

Live Preview
+1+1 (555) 123-4567
Controlled: use onValueChange to handle input

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add phone-input

Or install the full npm package:

npm install bigbullui
import { PhoneInput } from "@/components/ui/phone-input";

<PhoneInput defaultValue="+1 (555) 123-4567" onValueChange={(v) => {}} />

Usage guidance

Use PhoneInput when a ticket stub surface needs Phone number input with country code selector and formatted display Keep props minimal and prefer composition with sibling stubs.

Avoid PhoneInput for long form flows or dense data grids where a dedicated layout block fits better.

Source code

phone-input.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export interface CountryOption {
  code: string;
  label: string;
  flag?: string;
}

export interface PhoneInputProps {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  countries?: CountryOption[];
  className?: string;
  placeholder?: string;
}

const COUNTRIES: CountryOption[] = [
  { code: "US", label: "United States", flag: "🇺🇸" },
  { code: "TR", label: "Turkey", flag: "🇹🇷" },
  { code: "GB", label: "United Kingdom", flag: "🇬🇧" },
  { code: "DE", label: "Germany", flag: "🇩🇪" },
  { code: "FR", label: "France", flag: "🇫🇷" },
  { code: "CA", label: "Canada", flag: "🇨🇦" },
  { code: "AU", label: "Australia", flag: "🇦🇺" },
];

const formatPhone = (num: string, code: string) => {
  const digits = num.replace(/\D/g, "");
  if (digits.length === 0) return "";
  if (code === "US") {
    const d = digits.slice(1);
    if (d.length <= 3) return `+1 ${d}`;
    if (d.length <= 6) return `+1 (${d.slice(0, 3)}) ${d.slice(3)}`;
    return `+1 (${d.slice(0, 3)}) ${d.slice(3, 6)}-${d.slice(6, 9)}`;
  }
  return `+${code} ${digits}`;
};

export function PhoneInput({
  value: controlledValue,
  defaultValue = "",
  onValueChange,
  countries = COUNTRIES,
  className,
  placeholder = "Phone number",
}: PhoneInputProps) {
  const [internalValue, setInternalValue] = React.useState(defaultValue);
  const [selectedCountry, setSelectedCountry] = React.useState(countries[0]);
  const isControlled = controlledValue !== undefined;
  const currentValue = isControlled ? controlledValue : internalValue;

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const val = e.target.value;
    setInternalValue(val);
    onValueChange?.(val);
  };

  const handleCountryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    setSelectedCountry(countries.find(c => c.code === e.target.value) || countries[0]);
  };

  return (
    <div
      className={cn(
        "relative flex flex-col gap-2",
        className,
      )}
    >
      <div className="relative">
        <select
          value={selectedCountry.code}
          onChange={handleCountryChange}
          className={cn(
            "block w-full rounded-md border border-border bg-transparent px-3 py-2 font-mono text-sm text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
            "appearance-none bg-no-repeat bg-right-4/10 cursor-pointer",
          )}
        >
          {countries.map((country) => (
            <option
              key={country.code}
              value={country.code}
            >
              {country.flag ? `${country.flag} ${country.label}` : country.label}
            </option>
          ))}
        </select>
        <svg
          width="16"
          height="16"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          className="absolute right-3 top-1/2 -translate-y-1 text-muted-foreground"
        >
          <path d="M18 8h1a4 4 0 0 1 0 8h1a4 4 0 0 1 0 8h-1a4 4 0 0 1-4-4h-1a4 4 0 0 1-4-4z" />
          <line x1="6" y1="9" x2="6" y2="15" />
          <line x1="10" y1="9" x2="10" y2="15" />
        </svg>
      </div>

      <div>
        <input
          type="tel"
          value={currentValue}
          onChange={handleChange}
          placeholder={placeholder}
          className={cn(
            "block w-full rounded-md border-2 border-dashed border-input bg-transparent px-3 py-2 font-mono text-sm placeholder-text-muted-foreground focus-visible:outline-none focus-visible:border-solid focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 transition-colors",
            className,
          )}
        />
        {currentValue && (
          <div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
            {selectedCountry.code === "US" ? (
              <span className="relative after:content-['+'] after:absolute after:left-0 after:top-1/2 after:-translate-y-1/2 after:text-[10px]">
                +1
              </span>
            ) : null}
            <span>{currentValue || "&nbsp;"}</span>
          </div>
        )}
      </div>

      {isControlled ? null : (
        <small className="text-[10px] uppercase tracking-[0.15em] text-muted-foreground">
          Controlled: use onValueChange to handle input
        </small>
      )}
    </div>
  );
}

PhoneInput.displayName = "PhoneInput";

Props

PropTypeDescription
value?string
defaultValue?string
onValueChange?(value: string) => void
countries?CountryOption[]
className?string
placeholder?string