Masked Input
A generic pattern-masked text field — 9/A/* slots with literal separators, both the masked and the bare value on every change, and a caret that survives edits in the middle of the string.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/masked-input.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "MaskedInput" component (no runtime
dependencies beyond React and a cn() class merger).
Contract
- forwardRef whose ref points at the inner input; props extend
InputHTMLAttributes minus value / onChange / type.
- mask: string. "9" = digit, "A" = letter (Unicode-aware, so an IME can fill
it), "*" = letter or digit. Every other character is a literal the field
types for the user: "9999-9999-9999-9999", "(999) 999-9999", "99/99",
"AA-9999".
- Controlled: value + onValueChange(masked, raw). Every change hands back both
shapes — masked for display and storage, raw (slot characters only, no
separators) for validation and for your API. The masked value never carries
guide placeholders, so feeding it straight back is a fixed point.
- Options: placeholderChar (default "_"; "" turns the guide off), showMask
(default false), guide (default true), unmaskedValue (default false — value
is masked text; true means value holds the bare characters), onComplete
(masked, raw), invalid, disabled, className. Everything else is spread onto
the input, so aria-describedby / autoComplete / name stay the consumer's.
Behavior
- One pure conform() function is the source of truth: pour a candidate string
into the mask, slot by slot. Characters the current slot rejects are skipped
in place, so a pasted "4111 1111 1111 1111" and a re-fed masked string parse
to the same raw. It returns the display text, the raw characters, the display
index of every raw character and of every slot, and where the masked value
ends — the caret math is built entirely on those indices.
- Caret handling is the whole component. On change the browser has already
edited the DOM, so: (1) diff the new DOM string against the last rendered
masked text — the common prefix up to the caret plus the common suffix after
it bracket the edit, which turns typing, pasting, cutting and
selection-replacement into one "replace [start, end) with inserted";
(2) translate that range into raw-character space through the position table;
(3) rebuild raw = prefix + inserted + suffix and re-conform it; (4) put the
caret at the slot of the last inserted character that survived — if none did
(illegal keystroke) it does not move at all. Raw space is the only coordinate
system where "the caret is after N typed characters" survives re-masking,
which is why editing mid-string never flings the caret to the end.
- Deleting only literals or placeholders would be a no-op (the mask regenerates
them), so such a deletion hops one character further: Backspace eats the
character before the separator, Delete the one after it. The direction comes
from the native event's inputType (deleteContentForward / deleteWordForward)
— Backspace and Delete otherwise produce an identical value and caret.
- Write the conformed text back into the DOM node synchronously inside the
change handler, then call setSelectionRange. React's controlled-input update
only assigns node.value when it differs from what the node already holds, so
the re-render becomes a no-op and never resets the selection. A layout effect
re-applies the stored caret as a backstop for the commits where React does
rewrite the value (a parent that normalises the string, or a mask that
changed in the same render); it runs before paint, so the caret is never seen
sitting at the end of the field.
- IME: while nativeEvent.isComposing (or anywhere between compositionstart and
compositionend) the component holds the half-composed text in state and
renders it untouched — re-masking mid-composition reorders or drops
characters. compositionend runs the final text through the same diff, as one
insertion against the pre-composition masked text.
- Guide: with guide on, unfilled slots render placeholderChar and the literals
between them are drawn ahead of time; with it off (or placeholderChar "") the
display stops at the first unfilled slot, keeping the separator just earned.
An empty value renders "" so the native placeholder can show, unless showMask
asks for the whole template up front.
- onComplete fires once when the last slot is filled and is re-armed only after
the value falls out of completeness — it never repeats on later edits.
- inputMode is inferred: a mask whose slots are all "9" gets "numeric";
anything mixed keeps the full keyboard. aria-invalid follows invalid.
- Edges: an empty mask degrades to a plain text field instead of swallowing
every keystroke; a mask with no slots accepts nothing; over-long pastes stop
at the last slot; changing mask re-formats the existing value on the next
render, because the display is always derived and never stored.
- No animation anywhere, so there is nothing to gate on prefers-reduced-motion.
Rendering & styling
- One input, no wrapper: h-9 rounded-md border border-input bg-transparent
px-3 text-sm tabular-nums shadow-xs, placeholder:text-muted-foreground,
focus-visible:border-ring + focus-visible:ring-3 focus-visible:ring-ring/50,
disabled:opacity-50 + disabled:cursor-not-allowed, and border-destructive +
ring-destructive/30 when invalid. Semantic tokens only — dark mode is free.
- tabular-nums keeps guide characters and digits on the same rhythm so the
field does not jitter as slots fill; spellCheck is off because mask text is
not prose.
- Merge the consumer className through cn() and spread the remaining props
after the component's own, so every attribute stays overridable.
Customization levers
- Mask alphabet: the slot table is a plain map from character to predicate —
add "H" for hex, "N" for non-zero digits, or make "A" uppercase-only, and
every index in the caret math keeps working.
- Case handling: uppercase or lowercase the accepted character as it is stored
(a one-line transform inside the conform loop) for plates and postcodes.
- Dynamic masks: derive the mask prop from the value in the parent — card
brands (Amex "9999-999999-99999" vs Visa) or per-country phone lengths — and
the existing value is re-formatted automatically.
- Guide look: placeholderChar can be "_", "·", " " or ""; showMask decides
whether the template exists before the first keystroke. Pick "" plus a native
placeholder for the quietest field.
- Value shape: keep the masked string in state for a display-first form, or set
unmaskedValue and store the raw characters when your API wants them —
onValueChange gives you both either way.
- Validation: drive invalid and any helper text from your form library (zod
.length(16) over raw); the component only shapes input, it never blocks
submission.
- Density and typography: h-9 / px-3 / text-sm are the sizing knobs; swap
tabular-nums for font-mono when the field sits next to code.Concepts
- Conform, not validate — every candidate string is poured into the mask slot by slot, and characters the current slot cannot take are skipped in place rather than rejected loudly. One behaviour covers three cases: an illegal keystroke disappears, a pasted
4111 1111 1111 1111loses its spaces, and a masked value fed back in re-parses to the same characters. - Caret in raw space — display indices shift every time the mask re-renders, so the caret is carried as "after N typed characters" and mapped back onto a slot position at the end. Deleting a digit in the middle of a card number therefore leaves the caret exactly where the user put it, with the trailing digits sliding up one slot.
- Literal hop — separators are generated, so deleting one on its own would change nothing. A deletion that removed only literals or placeholders reaches one character further, backwards for Backspace and forwards for Delete; the direction is read from the native
inputType, because the two keys otherwise leave an identical value and caret behind. - Guide vs showMask —
guidedecides whether unfilled slots are drawn at all,showMaskwhether that template exists before the first keystroke. Guide characters live only in the display:onValueChangealways hands back a masked value without them, which is what makes the controlled loop a fixed point. - Two values, one change —
onValueChange(masked, raw)reports both shapes at once, so the display layer and the validation/API layer never re-derive one from the other and drift apart. - Composition pass-through — an IME builds a character over several events, and re-masking mid-composition would reorder or drop it. The field holds the half-composed text verbatim and only runs the mask when
compositionendcommits, treating the whole run as a single insertion.
Duration Input
A duration field that normalizes to seconds — segmented h/m/s spinbuttons that carry (type 90 minutes, get 1h 30m), or a free-text mode that parses "1h30", "90m" and "1:30".
Filter Bar
A composable filter bar for the toolbar above a table: an add-filter menu, one editable chip per active filter, and portalled editors for select, multiselect, date range, text and boolean fields.