# Masked Input

> A React masked input that formats itself as you type or paste: card number, expiry, CVC, IBAN, CNP, postal code or a custom mask.

- Type: React component (Inputs)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add masked-input`
- Docs: https://wingo-ui.com/components/masked-input

## Interaction

Type and the text shapes itself: spaces between card groups, a slash after the month, uppercase IBAN letters, and the caret always lands right after what you typed, skipping over the literals. Paste 4111-1111-1111-1111 or RO49 AAAA 1B31… with any separators and it is cleaned and re-masked; autofilled 12/2028 becomes 12/28. Backspace right after a space or slash deletes the digit before it, Delete the one after; selecting and typing replaces, and the browser's undo still works. In the expiry field a first digit from 2 to 9 is a month on its own (4 becomes 04/). With showMask the rest of the pattern shows as a faint ghost after the caret (on focus, or always). Card numbers show a card icon and the brand chip (Visa, Mastercard, Amex, Maestro, Discover) that pops in from the first digits. Once complete, a green check or a red alert icon pops in (Luhn, IBAN mod 97, CNP checksum and birth date, expiry not in the past); the red ring and aria-invalid wait until you leave the field. Enter submits the form. On a phone the keypad and autofill follow the preset (numeric for cards, CNP and postal codes, cc-number / cc-exp / cc-csc autofill), and the ghost keeps the format visible after the placeholder disappears.

## Installation

```bash
npx wingo-ui@latest add masked-input
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/masked-input` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/masked-input.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { MaskedInput, type MaskedInputDetails } from "@/components/ui/masked-input"

export function CardForm() {
  const [card, setCard] = useState("")
  const [details, setDetails] = useState<MaskedInputDetails | null>(null)
  const [touched, setTouched] = useState(false)

  return (
    <form className="flex flex-col gap-4">
      <MaskedInput
        preset="card-number"
        name="card"
        label="Card number"
        placeholder="1234 5678 9012 3456"
        value={card}
        onValueChange={(raw, info) => {
          setCard(raw)
          setDetails(info)
        }}
        onBlur={() => setTouched(true)}
        error={touched && details?.valid === false ? "The card number is not valid." : undefined}
        required
      />
      <div className="grid grid-cols-2 gap-3">
        <MaskedInput preset="card-expiry" name="expiry" label="Expiry" placeholder="MM/YY" showMask="focus" maskPlaceholder="MM/YY" />
        <MaskedInput preset="card-cvc" name="cvc" label="CVC" placeholder="123" enterKeyHint="done" />
      </div>
      <MaskedInput
        label="License plate (Romania)"
        mask={(raw) => (/^B\d/i.test(raw) ? "A 999 AAA" : "AA 99 AAA")}
        placeholder="CJ 12 ABC"
        autoCapitalize="characters"
      />
    </form>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `preset?` | `"none" \| "card-number" \| "card-expiry" \| "card-cvc" \| "iban" \| "cnp" \| "postal-code"` | `"none"` | A ready mask with its validator, inputMode and autoComplete. card-number: 16 digits in groups of 4 (up to 19, Amex 4-6-5), Luhn, brand chip. card-expiry: 99/99, smart month, not in the past. card-cvc: 3 digits. iban: country length, uppercase, mod 97. cnp: 13 digits, checksum, birth date, county 01-52. postal-code: 6 digits. The playground shows the card form while none has no mask. |
| `mask?` | `string \| ((raw: string) => string)` | `""` | The pattern: 9 a digit, a a letter, A a letter shown uppercase, * a letter or digit, \ escapes; anything else is a literal ("AA 99 AAA"). A function picks the pattern from the raw value typed so far (Amex cards, Bucharest plates). Overrides the preset's mask; the preset's validator stays. |
| `showMask?` | `"never" \| "focus" \| "always"` | `"never"` | The rest of the mask after the caret as a faint ghost (text-muted-foreground/60), aligned with the typed text. focus shows it while the field is focused; always keeps it visible and replaces the placeholder. A resting floating label hides it. |
| `maskPlaceholder?` | `string` | `""` | The ghost text for the whole mask, character by character ("mm/dd/yyyy" for 99/99/9999). Empty shows placeholderChar in each token. |
| `placeholderChar?` | `string` | `"_"` | The ghost character for each token when there is no maskPlaceholder. |
| `showValidity?` | `boolean` | `true` | A trailing CircleCheck (success) or CircleAlert (danger) that pops in once the value is complete, with a visually hidden Valid / Invalid. Needs a validator (the preset's or validate). |
| `showBrand?` | `boolean` | `true` | card-number: the leading card icon and the trailing brand chip read from the first digits. |
| `submitMasked?` | `boolean` | `false` | The hidden form input (rendered when name is set) sends the masked text (4111 1111 …) instead of the raw digits. The readout under the demo shows what the form gets. |
| `label?` | `ReactNode` | `""` | Renders the field anatomy around the box, linked with htmlFor. Leave it empty for a bare field (then pass aria-label). |
| `description?` | `ReactNode` | `""` | Helper text under the box, linked with aria-describedby; a good place for the format ("Format: LL/AA"). |
| `error?` | `ReactNode` | `""` | Replaces the description with the animated error and marks the field invalid. Say why the value failed (the demo does). |
| `required?` | `boolean` | `false` | The red mark after the label and the native required attribute. |
| `invalid?` | `boolean` | `false` | The invalid look and aria-invalid without a message. A complete value that fails validation sets it by itself after the first blur. |
| `disabled?` | `boolean` | `false` | Dims the field and blocks input; the hidden form input is disabled too, so nothing is submitted. The demo fills in the test card. |
| `readOnly?` | `boolean` | `false` | The value stays visible and selectable but cannot change. The demo fills in the test card. |
| `placeholder?` | `string` | `""` | Hint text while empty. Hidden while an always-visible ghost covers the field. |
| `defaultValue?` | `string` | `""` | The starting raw value while uncontrolled, with or without literals ("4111111111111111"). |
| `name?` | `string` | `""` | Renders a hidden input with this name and the raw value (or the masked text with submitMasked), so native forms and FormData get the clean value. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Height 32 / 40 / 48px with 13 / 14 / 15px text (16px on phones so iOS never zooms); md grows to 44px on touch. |
| `variant?` | `"soft" \| "outline" \| "underline" \| "ghost"` | `"soft"` | The Input's box look: a filled well, a bordered box, a bottom line or no fill until hover. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | Corner shape, scaled with the size like the button's; full makes a pill. Ignored by underline. |
| `accent?` | `string` | `""` | Any CSS color for the focus ring, the outline border on focus and the caret. Empty uses the ring token. |
| `floatingLabel?` | `boolean` | `false` | The label rests inside the box and floats up on focus or when filled; the ghost waits until it has floated. |
| `clearable?` | `boolean` | `false` | A clear button while there is a value; Escape clears from the keyboard. |
| `prefix?` | `ReactNode` | `""` | Text before the input in the muted addon look, like "RO" or "+40", or an element rendered as is. A click on it lands in the input. |
| `clearLabel?` | `string` | `"Clear"` | The clear button's accessible name and title (with clearable). |
| `loading?` | `boolean` | `false` | A small spinner at the end and aria-busy, e.g. while the server checks an IBAN. The field stays editable. |
| `loadingLabel?` | `string` | `"Loading"` | The spinner's visually hidden text. |
| `selectOnFocus?` | `boolean` | `false` | Selects the whole value when the field takes focus, by click, tap or Tab, so typing replaces it. |
| `enterKeyHint?` | `"enter" \| "done" \| "go" \| "next" \| "previous" \| "search" \| "send"` | `"next"` | The label of the phone keyboard's Enter key. next suits a field in a form; pass done on the last one. |
| `value?` | `string` |  | The raw value, controlled, without literals. A value longer than the mask shows cut but is not changed until the next edit. |
| `onValueChange?` | `(raw: string, details: { masked: string; complete: boolean; valid: boolean \| null; brand?: CardBrand \| null }) => void` |  | Fires on every edit with the raw value and the details: the masked text, whether every token is filled, the validator's verdict once complete (null before), and the card brand for card-number. |
| `validate?` | `(raw: string) => boolean` |  | Runs once the value is complete; replaces the preset's validator. Without one (and without a preset) valid stays null and no icon shows. The mask engine and the checks are exported too (see Parts), for server code, initial state and read-only displays. |
| `labels?` | `Partial<{ valid: string; invalid: string; brands: Partial<Record<CardBrand, string>> }>` |  | The visually hidden text of the validity icon ("Valid" / "Invalid") and the brand chip names. |
| `onChange?` | `(event: ChangeEvent<HTMLInputElement>) => void` |  | The native change event, untouched. Its target value is the masked text; read the raw value from onValueChange. |
| `leftIcon?` | `ReactNode` |  | A leading icon; card-number shows a card icon by default (with showBrand). |
| `suffix?` | `ReactNode` |  | Text or an element after the brand chip and the validity icon. |
| `rightIcon?` | `ReactNode` |  | A trailing icon after the brand chip and the validity icon; it crossfades with the spinner while loading. |
| `clearIcon?` | `ReactNode` |  | Replaces the clear button's icon (a 16px X by default). |
| `onClear?` | `() => void` |  | Called after the clear button or Escape emptied the field (with clearable). onValueChange fires too, with an empty raw value. |
| `fieldProps?` | `Partial<FieldProps>` |  | Anything else for the Field wrapper: optional and optionalLabel, labelAction, orientation, labelWidth, hideLabel, requiredIndicator. |
| `ref?` | `RefObject<HTMLInputElement \| null> or (node) => void` |  | The native input. Every other Input prop and native prop lands on it: inputMode, autoComplete and autoCapitalize (defaults from the preset), onBlur, aria-describedby. With react-hook-form use Controller (value / onValueChange), not register, since the element holds the masked text. |
| `classNames?` | `Partial<Record<InputSlot \| "ghost" \| "brand" \| "validity", string>>` |  | Extra classes for the Input's parts plus the ghost, the brand chip and the validity icon. |

## Parts

### applyMask (function)

applyMask(raw, mask) formats raw text with a pattern or a mask function: applyMask("4111111111111111", "9999 9999 9999 9999") returns "4111 1111 1111 1111".

### getMaskDetails (function)

getMaskDetails(raw, { preset, mask, validate }) returns what onValueChange hands back (masked, complete, valid, brand) for any raw value, e.g. for initial state or a server check.

### detectCardBrand (function)

detectCardBrand(digits) reads the brand from the first digits: "visa", "mastercard", "amex", "maestro", "discover" or null.

### isValidLuhn (function)

isValidLuhn(digits) runs the Luhn checksum card numbers use; spaces and dashes are ignored.

### isValidIban (function)

isValidIban(iban) checks the format, the length for known countries and the mod 97 checksum; spaces and case are ignored.

### isValidCnp (function)

isValidCnp(cnp, now?) validates a Romanian personal numeric code: the birth date, the county code and the control digit.

### isValidExpiry (function)

isValidExpiry(expiry, now?) accepts "MMYY" or "MM/YY" and is true through the last day of that month.

### IBAN_LENGTHS (constant)

The IBAN length per country code that isValidIban enforces (RO 24, DE 22, GB 22, FR 27 and a few more); other countries only get the checksum.

### MaskedInputDetails (type)

The details onValueChange receives: { masked, complete, valid, brand? }; valid is null until the value is complete.

## Examples

- **IBAN**: Letters turn uppercase, groups of four appear as you type and the length follows the country (22 for GB). A paste with spaces works; the second field fails the mod 97 check.
- **CNP (Romanian personal ID)**: Checks the control digit, the birth date and the county code. The mask shows on focus; leave the field to see Invalid CNP.
- **Smart expiry**: A first digit from 2 to 9 is a month on its own, so 4 becomes 04/ and the caret jumps past the slash. Past months are invalid.
- **Function mask**: A Romanian plate: the mask is picked from what was typed, AA 99 AAA for counties, A 999 AAA once it starts with B and a digit (Bucharest).
- **Always-visible mask**: showMask="always" with maskPlaceholder="mm/dd/yyyy": the format stays visible and the typed digits cover it one by one.
- **Raw or masked in a form**: With name, a hidden input carries the raw digits; submitMasked sends the formatted text instead. Submit to print the FormData.

## See also

- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [OTP Input](https://wingo-ui.com/components/otp-input) ([Markdown](https://wingo-ui.com/components/otp-input.md))
- [Number Input](https://wingo-ui.com/components/number-input) ([Markdown](https://wingo-ui.com/components/number-input.md))
- [Password Input](https://wingo-ui.com/components/password-input) ([Markdown](https://wingo-ui.com/components/password-input.md))

---

Canonical: https://wingo-ui.com/components/masked-input
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
