Masked Input React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
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 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.
Masked Input is a Pro React component in the React input components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add masked-input, together with Input, Field, Motion presets, useControllableState and UIProvider and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add masked-inputMasked Input is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import MaskedInput from @/components/ui/masked-input.
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
presetdefault"none""none" | "card-number" | "card-expiry" | "card-cvc" | "iban" | "cnp" | "postal-code"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.
maskdefault""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.
showMaskdefault"never""never" | "focus" | "always"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.
maskPlaceholderdefault""stringThe ghost text for the whole mask, character by character ("mm/dd/yyyy" for 99/99/9999). Empty shows placeholderChar in each token.
placeholderChardefault"_"stringThe ghost character for each token when there is no maskPlaceholder.
showValiditydefaulttruebooleanA 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).
showBranddefaulttruebooleancard-number: the leading card icon and the trailing brand chip read from the first digits.
submitMaskeddefaultfalsebooleanThe 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.
labeldefault""ReactNodeRenders the field anatomy around the box, linked with htmlFor. Leave it empty for a bare field (then pass aria-label).
descriptiondefault""ReactNodeHelper text under the box, linked with aria-describedby; a good place for the format ("Format: LL/AA").
errordefault""ReactNodeReplaces the description with the animated error and marks the field invalid. Say why the value failed (the demo does).
requireddefaultfalsebooleanThe red mark after the label and the native required attribute.
invaliddefaultfalsebooleanThe invalid look and aria-invalid without a message. A complete value that fails validation sets it by itself after the first blur.
disableddefaultfalsebooleanDims the field and blocks input; the hidden form input is disabled too, so nothing is submitted. The demo fills in the test card.
readOnlydefaultfalsebooleanThe value stays visible and selectable but cannot change. The demo fills in the test card.
placeholderdefault""stringHint text while empty. Hidden while an always-visible ghost covers the field.
defaultValuedefault""stringThe starting raw value while uncontrolled, with or without literals ("4111111111111111").
namedefault""stringRenders 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.
sizedefault"md""sm" | "md" | "lg"Height 32 / 40 / 48px with 13 / 14 / 15px text (16px on phones so iOS never zooms); md grows to 44px on touch.
variantdefault"soft""soft" | "outline" | "underline" | "ghost"The Input's box look: a filled well, a bordered box, a bottom line or no fill until hover.
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Corner shape, scaled with the size like the button's; full makes a pill. Ignored by underline.
accentdefault""stringAny CSS color for the focus ring, the outline border on focus and the caret. Empty uses the ring token.
floatingLabeldefaultfalsebooleanThe label rests inside the box and floats up on focus or when filled; the ghost waits until it has floated.
clearabledefaultfalsebooleanA clear button while there is a value; Escape clears from the keyboard.
prefixdefault""ReactNodeText 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.
clearLabeldefault"Clear"stringThe clear button's accessible name and title (with clearable).
loadingdefaultfalsebooleanA small spinner at the end and aria-busy, e.g. while the server checks an IBAN. The field stays editable.
loadingLabeldefault"Loading"stringThe spinner's visually hidden text.
selectOnFocusdefaultfalsebooleanSelects the whole value when the field takes focus, by click, tap or Tab, so typing replaces it.
enterKeyHintdefault"next""enter" | "done" | "go" | "next" | "previous" | "search" | "send"The label of the phone keyboard's Enter key. next suits a field in a form; pass done on the last one.
valuestringThe 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 }) => voidFires 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) => booleanRuns 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.
labelsPartial<{ 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>) => voidThe native change event, untouched. Its target value is the masked text; read the raw value from onValueChange.
leftIconReactNodeA leading icon; card-number shows a card icon by default (with showBrand).
suffixReactNodeText or an element after the brand chip and the validity icon.
rightIconReactNodeA trailing icon after the brand chip and the validity icon; it crossfades with the spinner while loading.
clearIconReactNodeReplaces the clear button's icon (a 16px X by default).
onClear() => voidCalled after the clear button or Escape emptied the field (with clearable). onValueChange fires too, with an empty raw value.
fieldPropsPartial<FieldProps>Anything else for the Field wrapper: optional and optionalLabel, labelAction, orientation, labelWidth, hideLabel, requiredIndicator.
refRefObject<HTMLInputElement | null> or (node) => voidThe 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.
classNamesPartial<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
functionapplyMask(raw, mask) formats raw text with a pattern or a mask function: applyMask("4111111111111111", "9999 9999 9999 9999") returns "4111 1111 1111 1111".
getMaskDetails
functiongetMaskDetails(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
functiondetectCardBrand(digits) reads the brand from the first digits: "visa", "mastercard", "amex", "maestro", "discover" or null.
isValidLuhn
functionisValidLuhn(digits) runs the Luhn checksum card numbers use; spaces and dashes are ignored.
isValidIban
functionisValidIban(iban) checks the format, the length for known countries and the mod 97 checksum; spaces and case are ignored.
isValidCnp
functionisValidCnp(cnp, now?) validates a Romanian personal numeric code: the birth date, the county code and the control digit.
isValidExpiry
functionisValidExpiry(expiry, now?) accepts "MMYY" or "MM/YY" and is true through the last day of that month.
IBAN_LENGTHS
constantThe 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
typeThe 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.
Files
- components/ui/masked-input.tsx
FAQ
Is the React Masked Input component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Masked Input?
Run npx wingo-ui@latest add masked-input. It also adds the Wingo UI items it builds on (Input, Field, Motion presets, useControllableState and UIProvider) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/masked-input.
How does Masked Input behave on phones?
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.
See also
More React input components
Articles using this component
- IBAN Validation JavaScript: Mod 97, Regex and a React Field
Component guides12 min read
- Romanian CNP Validator in TypeScript, Zod and React
Component guides13 min read