Holds a text field to one fixed shape while it is typed — a postal or ZIP code, a product or licence key, a serial, model or part number, an employee, member, account or policy number, a VIN, an IBAN, a SWIFT/BIC, a tracking number, an ISBN, a tax or national ID, a MAC address, a coupon or voucher code. You give it a mask: # is a digit, A a letter, * either, \ escapes the next character, and everything else is a separator the field writes for the person instead of asking them to type it. Common asks it answers: 'react input mask', 'masked input react', 'input mask shadcn', 'format input as you type react', 'react-input-mask alternative', 'cleave.js react', 'imask react', 'cursor jumps to end react input', 'caret jumps to end when formatting input', 'input formatting moves the cursor', 'react controlled input cursor position', 'cannot edit middle of formatted input', 'paste into masked input duplicates the dashes', 'backspace does nothing in masked input', 'strip formatting before submit react', 'store unformatted value react input', 'postal code input react', 'zip code mask', 'product key input component', 'license key input four groups', 'serial number input mask', 'VIN input react', 'IBAN input formatting', 'uppercase input react', 'numeric keyboard on mobile for code field'. shadcn/ui has nothing here at all: maskInput, inputMask, formatMask, placeholderChar, setSelectionRange and selectionStart each appear exactly zero times across the 245KB of component source its registry serves (62 of its 63 indexed items are actually served; questionnaire is listed but 404s on both style tracks), and its single mention of a caret is input-otp's animate-caret-blink, a decorative blinking div rather than a caret position. So this gets hand-written every time, and the hand-written version has the same three bugs. The caret is the first and the worst: reformatting on every keystroke replaces the whole value, so the caret goes to the end — which nobody notices while typing a fresh code left to right, and which makes the field unusable the moment someone goes back to fix the third character, because every keypress throws them to the end again. It is tracked here in typed characters rather than string offsets, since the separators move underneath it — typing the fourth digit of ###-#### inserts two characters where one was typed. Backspace and Delete are intercepted for the same reason: left to the browser, backspacing over a separator deletes it, the reformat puts it straight back, and the key looks broken, so those keys remove a typed character and let the separator follow. Pasting is the second: a field that strips separators and re-inserts them cannot tell one the person pasted from one it is about to add, and produces 123--4567. Here there is no paste handler at all — pasted text, typed text and a value handed over by a parent are read through the mask by one walk, so a separator the text already carries is consumed by the slot that was going to write one, and a character that does not fit its slot is dropped rather than slid sideways into the next slot that would take it, because silently reordering what someone typed is worse than ignoring a keystroke they can see did nothing. What gets submitted is the third: the field shows 123-4567 and reports 1234567, with the formatted string beside it rather than instead of it, so a controlled parent that stores what it is handed stores the value and not the presentation. name submits the raw value, formattedName submits the formatted one when the separators are part of what you store. Not the component for a format another pulld field already owns — date-input, time-input, phone-input, currency-input and otp-input each carry rules a mask cannot express (a month that stops at twelve, a calling code that decides how many digits follow it, a decimal separator that moves with the locale, a box per digit), so use those for dates, times, phone numbers, money and one-time codes, and this one for the shapes that are only a shape. It also makes no claim about whether the contents are real: a mask is a shape and not a checksum — an IBAN has mod-97, a VIN has a check digit, a card has Luhn — so isMaskComplete reports that every slot is filled and stops there, and the field never turns red on the second keystroke. The rest of the API: tokens adds or replaces the slot characters, which is how a VIN excludes I, O and Q that no general table knows about (tokens={{ ...MASK_TOKENS, V: /[A-HJ-NPR-Z0-9]/ }}); transform folds to uppercase or lowercase for keys, VINs and IBANs; onValueChange reports the raw value plus { formatted, complete } on every keystroke; a mask that changes — a postal code whose shape follows the country above it — takes the value with it and tells the parent. Accessibility is where a mask usually fails quietly: ___-____ in a placeholder is read out as underscores or skipped entirely, so describeMask says the shape in words ("3 digits, then 4 digits") into an sr-only description, and a caller's own aria-describedby is kept alongside it rather than overwritten; pass hint for another language or null to drop it. inputMode is derived, so an all-digit mask opens the number pad on a phone and a mixed one does not; type is text, so a leading zero survives and the separators can exist at all; autoCapitalize, autoCorrect, autoComplete and spellCheck default off, because a mobile keyboard rewrites a serial number into a word between the keypress and the change event, and all four can be overridden by a field that does have a browser entry. Nothing rendered depends on feature detection, so the server and the first client render agree and there is no hydration mismatch. formatWithMask, unmask, isMaskComplete and describeMask are exported as pure functions for the places the same answer is needed outside React — a confirmation screen, an admin table, a route handler checking what arrived, a migration normalising a column written both ways. Styled with shadcn tokens (input, ring, muted-foreground) for light and dark, className merges rather than fights, focus ring is focus-visible. One file, zero dependencies.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/masked-input.json"