Skip to content

Every app with a sign-up form hits the phone field question eventually. You can install a finished phone input component with flags, a searchable dropdown and formatting built in. Or you can use a headless phone input: a piece of logic that manages country, digits and validation, and leaves every pixel to you.

I maintain a headless one (@erag/phone-number-vue, with a React twin), so I'm not neutral. But there are plenty of projects where a finished component is the better call. This post lays out the trade-offs so you can pick on purpose instead of by habit.

What "headless" means for a phone input ​

A ready-made component ships three things together: behaviour, markup and styles. You get a <PhoneInput> tag, pass a few props, and it renders a flag button, a dropdown, an input and its own CSS.

A headless phone input ships only the behaviour. With usePhoneNumber, that's:

  • countryOptions, the list of countries to render however you like
  • selectedCountry, a writable ref for the current country
  • localPhone, the digits the user typed, normalized
  • callingCode and mask for the selected country
  • isValid, a length check against the country's allowed lengths
  • handleInput, one handler that accepts an input event, a string, or a country object

There's no template and no stylesheet. The introduction page says it plainly: if you need a fully styled component with flags and dropdowns, you may need a different package. I'd rather say that up front than have someone find out after installing it.

Headless vs ready-made at a glance ​

ConcernReady-made componentHeadless phone input
Time to first working fieldMinutesLonger, you write the markup
Matches your design systemOnly after overriding its CSSBy default, it uses your components
Flag icons, searchable dropdownUsually includedYou build or reuse them
AccessibilityDepends on the library's choicesDepends on your markup
Validation depthVaries, some ship full numbering-plan dataLength per country in this package
Bundle impactComponent, styles, often metadata and flag assetsLogic plus country data
UpgradesMarkup or CSS can change under youYour markup never changes unless you change it

None of these rows is a clear win for either side. It depends on what your app already has.

Where ready-made components win ​

Speed. If you need a phone field by this afternoon and nobody cares what it looks like, a finished component is hard to beat.

Features you'd otherwise build. A searchable country list with flags, keyboard navigation and "type to jump" is real work. Good component libraries have already done it, and have fixed the edge cases users reported.

Deeper validation. Some ready-made inputs bundle full numbering-plan metadata, so they can tell a mobile prefix from a landline one. If your product needs that, a length check won't do.

No design system yet. If your app uses default browser styling or a very light theme, there's nothing to clash with.

Where a headless phone input wins ​

It uses the components you already have. This is the main reason I built one. Most apps past the prototype stage have a BaseSelect, a BaseInput, focus rings, error styles and dark mode. A finished phone component arrives with its own versions of all of those, and you spend time making it look like it belongs.

With a headless composable, you pass the state into your own components. Because handleInput accepts a plain string or a country object as well as an event, it plugs straight into components that emit values instead of DOM events:

vue
<!-- resources/js/components/AccountPhone.vue -->
<script setup lang="ts">
import { usePhoneNumber } from '@erag/phone-number-vue'
import BaseSelect from '@/components/ui/BaseSelect.vue'
import BaseInput from '@/components/ui/BaseInput.vue'

const { selectedCountry, countryOptions, localPhone, callingCode, mask, isValid, handleInput } =
  usePhoneNumber({ countryCode: 'IN' })
</script>

<template>
  <div class="flex gap-2">
    <BaseSelect
      :model-value="selectedCountry"
      :options="countryOptions"
      option-label="name"
      @update:model-value="handleInput"
    />

    <BaseInput
      :model-value="localPhone"
      :prefix="callingCode ?? ''"
      :placeholder="mask"
      :invalid="localPhone.length > 0 && !isValid"
      inputmode="numeric"
      @update:model-value="handleInput"
    />
  </div>
</template>

BaseSelect and BaseInput stand in for whatever your project already has. When the select emits a country object, handleInput switches the country. When the input emits a string, it normalizes the digits. Same handler, both cases, and it's all in the API reference.

Accessibility is in your hands. That cuts both ways, but if your team already has an accessible select and input, you keep those guarantees. You're not auditing a second implementation of a dropdown.

Less CSS fighting. No overriding a library's specificity, no !important to fix a border radius, no surprise when an upgrade renames a class.

Your data, your rules. The country list and lengths can be replaced with your own, static or from an API. If you only serve three countries, you pass three countries.

The costs of going headless ​

I don't want to oversell it. With a headless phone input you are signing up for some work:

  • You write the dropdown. A native <select> works and is accessible, but it can't show flag images or a search box. Anything fancier is yours to build or reuse.
  • Flags are up to you. The country type has optional flag and flag_url fields, so if your country data includes them you can render them. You still have to decide how.
  • Validation is length-only. isValid checks that the digit count matches one of the selected country's allowed lengths. That catches the common mistakes, but not every invalid number. I cover what that means in practice in the Vue phone validation guide.
  • No display formatting. The composable gives you a mask pattern (like XXXXX XXXXX) and raw digits. It doesn't insert spaces as the user types. If you want that, you write it.

How to choose ​

A few questions settle it for most teams.

Do you have a component library or design system? If yes, lean headless. The time you'd spend restyling a finished component is roughly the time it takes to wire logic into your own.

Do you need numbering-plan accuracy? If you must distinguish mobile from landline, or reject specific ranges, pick a solution that bundles that data. Length checks won't get you there.

Is this a prototype? Use whatever renders fastest. You can swap later, because the value you store (a full international number) doesn't depend on which input produced it.

How many places use the field? One form on an internal tool doesn't justify much thought. A field on sign-up, checkout and settings in a customer-facing app is worth owning.

A middle path ​

Going headless doesn't mean building from zero every time. Build one PhoneField component on top of the composable, style it once with your design system, and reuse it everywhere. From then on it behaves exactly like a ready-made component for the rest of your team, except it's yours.

That's the setup I recommend, and it's what the phone input tutorial walks through step by step.

FAQ ​

Is a headless phone input harder to make accessible? ​

Not harder, just your responsibility. If you use a native <select> and a labelled <input>, you start from a good baseline.

Can I switch from a ready-made component to a headless one later? ​

Yes. Store the full international number and the country code, and the input that produced them is a UI detail you can replace.

Is there a React version? ​

Yes, @erag/phone-number-react follows the same design as a hook. The React phone number input tutorial covers it.

Where to go next ​

If your app already has form components, try the headless route on one form and see how much code it really takes. If it doesn't, and you need flags and search today, a finished component is a reasonable choice. Just make sure whatever you pick stores the number in a format you won't regret.

Last updated:

Written by Amit Gupta. Code samples are MIT licensed.