Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * Mask alphabet: `9` a digit, `A` a letter (Unicode-aware, so an IME can fill it),
 * `*` a letter or a digit. Every other character is a literal the field types for you.
 */
const SLOTS: Record<string, ((char: string) => boolean) | undefined> = {
  "9": char => char >= "0" && char <= "9",
  A: char => /\p{L}/u.test(char),
  "*": char => /[\p{L}0-9]/u.test(char),
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/masked-input.json

Prompt

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 1111 loses 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 showMaskguide decides whether unfilled slots are drawn at all, showMask whether that template exists before the first keystroke. Guide characters live only in the display: onValueChange always hands back a masked value without them, which is what makes the controlled loop a fixed point.
  • Two values, one changeonValueChange(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 compositionend commits, treating the whole run as a single insertion.

On This Page