Phone Input

Country Mask

A phone field whose mask changes with the country: typed and pasted numbers are brought to the same shape.

  • phone
  • mask
  • format
  • client

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/phoneinput-002?lang=en

Россия: 10 цифр после +7.

Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "phoneinput-002" (Country Mask) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/phoneinput-002.json

Registry item: https://vibeui.ru/r/phoneinput-002.json
Installs to: components/vibeui/phoneinput-002.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
A phone field whose mask changes with the country: typed and pasted numbers are brought to the same shape.

A phone field with a per-country mask: the template lives in the country record, and input and paste are normalised to digits. One file, zero dependencies, state on useState.

## 3. How to use it
import { Phoneinput002 } from "@/components/vibeui/phoneinput-002"

<Phoneinput002
  label="Phone"
  countries={[{ flag: "🇩🇪", code: "+49", name: "Germany", mask: "#### #######" }]}
/>

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-phoneinput-002-* palette — do not swap it for your theme tokens
- normalising to digits on input: without it pasting "+7 (999) 123-45-67" breaks the mask
- keeping the mask in the country record rather than the field: otherwise switching the code leaves a foreign template
- clearing the digits when the country changes — old digits in a new template produce a number that does not exist
- tabular figures: proportional ones make the number jerk on every character
- the block's own light surface: without it the dark label disappears on a dark background

## 6. You may change
- the field name through label and the country set with templates through countries
- the focus color through accent
- the templates themselves: # is a digit slot, everything else is inserted as is
- the placeholder: it is built from the template by replacing # with zero

## 7. Rules
- The caret jumps to the end when typing mid-string — that is the cost of a simple implementation without selectionStart handling.
- A mask is a hint about shape, not a validation: verify the real number on the server.
- The country list here is short and static: a full code directory weighs more than the component and should come from outside.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/phoneinput-002.json
https://vibeui.ru/r/phoneinput-002.json

Клиентский компонент: страна и голые цифры лежат в useState, отображаемое значение собирается функцией applyMask из шаблона страны. Один файл, без зависимостей, палитра в локальных переменных --vibeui-phoneinput-002-*. Маска не зашита в поле, а описана в объекте страны, поэтому смена кода меняет и шаблон. Форматирование идёт от цифр: на входе выбрасывается всё, что не цифра, — значит «+7 (999) 123-45-67» из буфера превращается в тот же номер, что и набранный руками. Длина ограничена числом мест в шаблоне, лишние цифры просто не попадают внутрь. Цифры набраны табличными: пропорциональные заставляют номер дёргаться на каждом символе.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useId, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Phoneinput002Country = {
  flag: string
  code: string
  name: string
  mask: string
}

export type Phoneinput002Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children"
> & {
  label?: string
  countries?: Phoneinput002Country[]
  accent?: string
}

// Идея компонента: маска зависит от страны, поэтому она не зашита в поле,
// а живёт в описании страны и меняется вместе с кодом. Форматирование
// работает от голых цифр: всё, что не цифра, выбрасывается на входе,
// поэтому вставленный из буфера «+7 (999) 123-45-67» превращается в тот
// же номер, что и набранный вручную. Лишние цифры не отбрасываются —
// они просто не попадают в шаблон.
const STYLES = `
:where([data-vibeui-block="phoneinput-002"]){
--vibeui-phoneinput-002-surface:oklch(1 0 0);
--vibeui-phoneinput-002-surface-border:oklch(0.91 0.006 265);
--vibeui-phoneinput-002-fg:oklch(0.24 0.016 265);
--vibeui-phoneinput-002-muted:oklch(0.54 0.014 265);
--vibeui-phoneinput-002-field-border:oklch(0.85 0.01 265);
--vibeui-phoneinput-002-accent:oklch(0.5 0.16 165);
--vibeui-phoneinput-002-radius:0.625rem;
--vibeui-phoneinput-002-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="phoneinput-002"]{
box-sizing:border-box;width:100%;max-width:22rem;
padding:1rem;border-radius:0.875rem;
background:var(--vibeui-phoneinput-002-surface);
border:1px solid var(--vibeui-phoneinput-002-surface-border);
font-family:var(--vibeui-phoneinput-002-font);color:var(--vibeui-phoneinput-002-fg);
display:flex;flex-direction:column;gap:0.375rem;
}
[data-vibeui-block="phoneinput-002"] label{
font-size:0.875rem;font-weight:600;line-height:1.3;cursor:pointer;
}
[data-vibeui-block="phoneinput-002"] [data-part="group"]{
display:flex;align-items:stretch;
border:1px solid var(--vibeui-phoneinput-002-field-border);
border-radius:var(--vibeui-phoneinput-002-radius);
background:var(--vibeui-phoneinput-002-surface);overflow:hidden;
transition:border-color .16s ease,box-shadow .16s ease;
}
[data-vibeui-block="phoneinput-002"] [data-part="group"]:has(:focus-visible){
border-color:var(--vibeui-phoneinput-002-accent);
box-shadow:0 0 0 3px color-mix(in oklab,var(--vibeui-phoneinput-002-accent) 22%,transparent);
}
[data-vibeui-block="phoneinput-002"] [data-part="code"]{position:relative;display:flex;flex:none}
[data-vibeui-block="phoneinput-002"] select{
appearance:none;-webkit-appearance:none;
box-sizing:border-box;height:2.5rem;padding:0 1.5rem 0 0.75rem;
font:inherit;font-size:0.9375rem;line-height:1.2;
color:var(--vibeui-phoneinput-002-fg);background:transparent;
border:0;border-right:1px solid var(--vibeui-phoneinput-002-field-border);
cursor:pointer;outline:none;
}
[data-vibeui-block="phoneinput-002"] [data-part="arrow"]{
position:absolute;right:0.5rem;top:50%;pointer-events:none;
width:0.3125rem;height:0.3125rem;
border-right:1.5px solid var(--vibeui-phoneinput-002-muted);
border-bottom:1.5px solid var(--vibeui-phoneinput-002-muted);
translate:0 -0.1875rem;rotate:45deg;
}
/* Табличные цифры: в маске с разделителями пропорциональные цифры
   заставляют номер дёргаться при каждом символе. */
[data-vibeui-block="phoneinput-002"] input{
box-sizing:border-box;width:100%;height:2.5rem;padding:0 0.75rem;min-width:0;
font:inherit;font-size:0.9375rem;font-variant-numeric:tabular-nums;
color:var(--vibeui-phoneinput-002-fg);background:transparent;
border:0;outline:none;
}
[data-vibeui-block="phoneinput-002"] input::placeholder{color:var(--vibeui-phoneinput-002-muted)}
[data-vibeui-block="phoneinput-002"] [data-part="hint"]{
margin:0;font-size:0.8125rem;line-height:1.45;
color:var(--vibeui-phoneinput-002-muted);
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="phoneinput-002"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_COUNTRIES: Phoneinput002Country[] = [
  { flag: "🇷🇺", code: "+7", name: "Россия", mask: "### ###-##-##" },
  { flag: "🇧🇾", code: "+375", name: "Беларусь", mask: "## ###-##-##" },
  { flag: "🇩🇪", code: "+49", name: "Германия", mask: "#### #######" },
  { flag: "🇫🇷", code: "+33", name: "Франция", mask: "# ## ## ## ##" },
]

/** Раскладывает голые цифры по шаблону, где # — место под цифру. */
function applyMask(digits: string, mask: string) {
  let result = ""
  let index = 0

  for (const symbol of mask) {
    if (index >= digits.length) {
      break
    }

    if (symbol === "#") {
      result += digits[index]
      index += 1
    } else {
      result += symbol
    }
  }

  return result
}

/**
 * Телефон с маской, которая меняется вместе со страной: ввод и вставка
 * из буфера приводятся к одному виду. Один файл, ноль зависимостей.
 */
export function Phoneinput002({
  label = "Телефон",
  countries = DEFAULT_COUNTRIES,
  accent,
  className,
  style,
  ...props
}: Phoneinput002Props) {
  const id = useId()
  const hintId = `${id}-hint`
  const [countryName, setCountryName] = useState(countries[0]?.name ?? "")
  const [digits, setDigits] = useState("")
  const country =
    countries.find((entry) => entry.name === countryName) ?? countries[0]
  const limit = (country?.mask.match(/#/g) ?? []).length
  const palette = {
    ...(accent ? { "--vibeui-phoneinput-002-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-phoneinput-002" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="phoneinput-002"
        className={className}
        style={palette}
      >
        <label htmlFor={id}>{label}</label>
        <div data-part="group">
          <span data-part="code">
            <select
              name="country"
              aria-label="Код страны"
              value={countryName}
              onChange={(event) => {
                setCountryName(event.target.value)
                setDigits("")
              }}
            >
              {countries.map((entry) => (
                <option key={entry.name} value={entry.name}>
                  {entry.flag} {entry.code}
                </option>
              ))}
            </select>
            <span data-part="arrow" aria-hidden="true" />
          </span>
          <input
            id={id}
            name="phone"
            type="tel"
            inputMode="tel"
            autoComplete="tel-national"
            placeholder={country?.mask.replaceAll("#", "0")}
            value={applyMask(digits, country?.mask ?? "")}
            aria-describedby={hintId}
            onChange={(event) =>
              setDigits(
                event.target.value.replace(/\D/g, "").slice(0, limit || 15),
              )
            }
          />
        </div>
        <p data-part="hint" id={hintId}>
          {country?.name}: {limit} цифр после {country?.code}.
        </p>
      </div>
    </>
  )
}