Phone Input
A phone field with a country picker and as-you-type formatting; the caret stays put while spaces are inserted.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/phone-input.json'use client'
import { useId, useLayoutEffect, useRef, useState } from 'react'
import './phone-input.css'
export interface Country { code: string; name: string; dial: string; pattern: string }
export interface PhoneInputProps {
countries: Country[]
defaultCountry?: string
onValueChange?: (e164: string, valid: boolean) => void
label?: string
fill?: 'dark' | 'light'
className?: string
}
export function formatPhone(digits: string, pattern: string) {
let out = '', i = 0
for (const ch of pattern) {
if (i >= digits.length) break
out += ch === '#' ? digits[i++] : ch
}
return out
}
/** Country-aware phone input with as-you-type formatting. */
export function PhoneInput({ countries, defaultCountry, onValueChange, label = 'Phone number', fill = 'dark', className }: PhoneInputProps) {
const [cc, setCc] = useState(countries.find((c) => c.code === defaultCountry) ?? countries[0])
const [digits, setDigits] = useState('')
const [open, setOpen] = useState(false)
const [touched, setTouched] = useState(false)
const ref = useRef<HTMLInputElement>(null)
const uid = useId()
const caret = useRef<number | null>(null)
const max = (cc.pattern.match(/#/g) ?? []).length
const valid = digits.length === max
const shown = formatPhone(digits, cc.pattern)
useLayoutEffect(() => {
if (caret.current === null || !ref.current) return
let seen = 0, pos = 0
while (pos < shown.length && seen < caret.current) { if (/\d/.test(shown[pos])) seen++; pos++ }
ref.current.setSelectionRange(pos, pos)
caret.current = null
}, [shown])
const update = (d: string, c = cc) => { setDigits(d); onValueChange?.(`${c.dial}${d}`, d.length === (c.pattern.match(/#/g) ?? []).length) }
return (
<div className={['sd-phn', className].filter(Boolean).join(' ')} data-fill={fill}>
<label className="sd-phn__lab" htmlFor={uid}>{label}</label>
<div className="sd-phn__box">
<button type="button" className="sd-phn__cc" aria-haspopup="listbox" aria-expanded={open} aria-label={`Country: ${cc.name} ${cc.dial}`} onClick={() => setOpen(!open)}>
<span className="sd-phn__iso">{cc.code}</span>{cc.dial}<svg viewBox="0 0 10 10" aria-hidden><path d="m2 3.5 3 3 3-3" /></svg>
</button>
<input
ref={ref}
id={uid}
className="sd-phn__in"
type="tel"
inputMode="tel"
autoComplete="tel-national"
placeholder={cc.pattern.replace(/#/g, '0')}
aria-invalid={touched && digits.length > 0 && !valid}
value={shown}
onBlur={() => setTouched(true)}
onChange={(e) => {
const el = e.target
caret.current = el.value.slice(0, el.selectionStart ?? el.value.length).replace(/\D/g, '').length
update(el.value.replace(/\D/g, '').slice(0, max))
}}
/>
{valid && <svg viewBox="0 0 16 16" className="sd-phn__ok" aria-label="Valid number" role="img"><path d="m3.5 8.5 3 3 6-7" /></svg>}
</div>
{touched && digits.length > 0 && !valid && <p className="sd-phn__err" role="alert">Enter {max} digits for {cc.name}.</p>}
{open && (
<div className="sd-phn__list" role="listbox" aria-label="Country">
{countries.map((c) => (
<button key={c.code} type="button" role="option" aria-selected={c.code === cc.code} className="sd-phn__opt" onClick={() => { setCc(c); setOpen(false); const d = digits.slice(0, (c.pattern.match(/#/g) ?? []).length); update(d, c); ref.current?.focus() }}>
<span className="sd-phn__iso">{c.code}</span><span>{c.name}</span><span>{c.dial}</span>
</button>
))}
</div>
)}
</div>
)
}.sd-phn { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-phn[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-phn button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-phn :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-phn .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-phn { position: relative; width: min(100%, 320px); align-self: start; margin-top: 40px; }
.sd-phn__lab { display: block; font-size: 12.5px; color: var(--mute); margin: 0 0 6px 2px; }
.sd-phn__box { display: flex; align-items: center; height: 46px; border-radius: 13px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); transition: box-shadow 200ms; }
.sd-phn__box:focus-within { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-phn__cc { display: flex; align-items: center; gap: 6px; height: 100%; padding: 0 10px 0 13px; border-right: 1px solid var(--line); font-size: 13.5px; font-weight: 500; font-variant-numeric: tabular-nums; }
.sd-phn__cc svg { width: 10px; height: 10px; fill: none; stroke: var(--mute); stroke-width: 1.8; stroke-linecap: round; }
.sd-phn__iso { font: 600 10.5px/1 ui-monospace, monospace; padding: 3px 5px; border-radius: 5px; background: var(--soft); }
.sd-phn__in { flex: 1; min-width: 0; height: 100%; padding: 0 12px; border: 0; outline: 0; background: none; color: var(--fg); font: inherit; font-size: 15px; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }
.sd-phn__in::placeholder { color: var(--mute); opacity: 0.6; }
.sd-phn__ok { width: 18px; height: 18px; margin-right: 12px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; animation: sd-phn-pop 300ms cubic-bezier(0.3, 1.7, 0.4, 1); }
@keyframes sd-phn-pop { from { transform: scale(0); } }
.sd-phn__list { position: absolute; top: 76px; left: 0; width: 100%; max-height: 220px; overflow: auto; padding: 5px; border-radius: 13px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line), 0 18px 40px -18px rgb(0 0 0 / 0.5); z-index: 3; animation: sd-phn-in 160ms ease; }
@keyframes sd-phn-in { from { opacity: 0; transform: translateY(-4px); } }
.sd-phn__opt { display: flex; align-items: center; gap: 10px; width: 100%; height: 36px; padding: 0 9px; border-radius: 8px; font-size: 13.5px; text-align: left; }
.sd-phn__opt:hover, .sd-phn__opt:focus-visible, .sd-phn__opt[aria-selected='true'] { background: var(--soft) !important; outline: 0; }
.sd-phn__opt span:last-child { margin-left: auto; color: var(--mute); font-variant-numeric: tabular-nums; }
.sd-phn__err { margin: 6px 0 0 2px; font-size: 12px; color: var(--fg); }
@media (prefers-reduced-motion: reduce) { .sd-phn *, .sd-phn *::before, .sd-phn *::after { animation: none !important; transition: none !important; } }Overview
Pick a country from a compact searchable list and the dial code and format pattern change with it. Digits are grouped as you type without the caret jumping.
A tick appears once the number has the right length for that country. The value you get back is E.164.
When to use
- Sign-up, checkout and 2FA phone collection.
When not to use
- Collecting many international numbers with edge cases: use libphonenumber.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/phone-input.json$
npx shadcn@latest add https://sundaeui.dev/r/phone-input.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/phone-input.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/phone-input.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy phone-input.tsx and phone-input.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { PhoneInput } from '@/components/sundae/phone-input'
<PhoneInput />Props
| Prop | Type | Default |
|---|---|---|
countries*Countries with a # pattern such as "### ### ####". | { code: string; name: string; dial: string; pattern: string }[] | — |
defaultCountryISO code. | string | — |
onValueChangeCalled on input. | (e164: string, valid: boolean) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The country picker is a listbox button with the dial code in its name.
- Validity is exposed with aria-invalid only after blur.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-phn; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Phone Input free?
- Yes. Phone Input is free for personal and commercial projects, with full source on this page.
- How do I install Phone Input?
- Run npx shadcn@latest add https://sundaeui.dev/r/phone-input.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Phone Input support keyboard and screen readers?
- The country picker is a listbox button with the dial code in its name. Validity is exposed with aria-invalid only after blur.
- Does Phone Input respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →OTP Field
Forms · Refined- Pro
Wheel Picker
Forms · Refined Password Strength
Forms · RefinedUnit Input
Forms · RefinedTime Field
Forms · RefinedBirthday Field
Forms · Refined