Masked Input
Inputs & Forms

Masked Input

A pattern-masked field for phones, expiries, and IDs that ghosts the remaining pattern ahead of the caret, inserts literals as you type, normalizes pasted text, and deletes cleanly through separators.

Install

npx shadcn@latest add @paragon/masked-input

masked-input.tsx

"use client";

import * as React from "react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";

const TOKENS: Record<string, RegExp> = {
  "9": /[0-9]/,
  a: /[a-zA-Z]/,
  "*": /[0-9a-zA-Z]/,
};
const isToken = (c: string) => c in TOKENS;

/** Accepted chars from free text, matched against the mask's token sequence. */
function extractRaw(mask: string, text: string, uppercase: boolean) {
  const tokens = [...mask].filter(isToken);
  let raw = "";
  for (const char of text) {
    const token = tokens[raw.length];
    if (!token) break;
    if (TOKENS[token].test(char))
      raw += uppercase ? char.toUpperCase() : char;
  }
  return raw;
}

/** Apply the mask, eagerly appending literals after the last accepted char. */
function applyMask(mask: string, raw: string) {
  if (!raw) return "";
  let out = "";
  let ri = 0;
  for (const m of mask) {
    if (isToken(m)) {
      if (ri >= raw.length) break;
      out += raw[ri++];
    } else {
      out += m;
    }
  }
  return out;
}

/** Caret position after the k-th accepted char, skipping trailing literals. */
function caretAfter(mask: string, formatted: string, k: number) {
  let count = 0;
  let i = 0;
  for (; i < formatted.length; i++) {
    if (isToken(mask[i])) {
      count++;
      if (count === k) {
        i++;
        break;
      }
    }
  }
  if (k <= 0) i = 0;
  while (i < formatted.length && !isToken(mask[i])) i++;
  return Math.min(i, formatted.length);
}

function defaultHint(mask: string) {
  return [...mask]
    .map((m) => (m === "9" ? "0" : m === "a" ? "A" : m === "*" ? "·" : m))
    .join("");
}

export interface MaskedInputProps
  extends Omit<
    React.ComponentProps<"input">,
    "value" | "defaultValue" | "onChange" | "type"
  > {
  /** Pattern: `9` digit, `a` letter, `*` alphanumeric; anything else literal. */
  mask: string;
  /** Ghost template ahead of the caret. Defaults to one derived from the mask. */
  hint?: string;
  /** Show the ghosted remainder of the pattern. */
  showGhost?: boolean;
  /** Uppercase accepted letters (plates, IBANs). */
  uppercase?: boolean;
  /** Controlled raw value (accepted chars only, no literals). */
  value?: string;
  /** Initial raw value when uncontrolled. */
  defaultValue?: string;
  /** Fires with the raw and formatted values on every change. */
  onValueChange?: (raw: string, formatted: string) => void;
  /** Fires once each time every slot in the mask is filled. */
  onComplete?: (formatted: string) => void;
}

/**
 * A pattern-masked field — phone, card expiry, EIN, plates. Literal characters
 * are inserted as you type and ghosted ahead of the caret so the shape of the
 * value is always visible; pasted text is normalized against the mask;
 * deleting through a literal removes the character before it.
 */
export function MaskedInput({
  mask,
  hint,
  showGhost = true,
  uppercase = false,
  value: valueProp,
  defaultValue = "",
  onValueChange,
  onComplete,
  className,
  disabled,
  placeholder,
  ...props
}: MaskedInputProps) {
  const tokenCount = React.useMemo(
    () => [...mask].filter(isToken).length,
    [mask],
  );
  const [uncontrolled, setUncontrolled] = React.useState(
    extractRaw(mask, defaultValue, uppercase),
  );
  const raw = valueProp !== undefined
    ? extractRaw(mask, valueProp, uppercase)
    : uncontrolled;
  const formatted = applyMask(mask, raw);

  const inputRef = React.useRef<HTMLInputElement>(null);
  const pendingCaret = React.useRef<number | null>(null);
  const composing = React.useRef(false);
  const wasComplete = React.useRef(raw.length === tokenCount && tokenCount > 0);

  const complete = tokenCount > 0 && raw.length === tokenCount;

  React.useEffect(() => {
    if (complete && !wasComplete.current) onComplete?.(formatted);
    wasComplete.current = complete;
  }, [complete, formatted, onComplete]);

  React.useLayoutEffect(() => {
    const input = inputRef.current;
    if (pendingCaret.current === null || !input) return;
    if (document.activeElement === input) {
      input.setSelectionRange(pendingCaret.current, pendingCaret.current);
    }
    pendingCaret.current = null;
  });

  const processText = (text: string, caret: number, deletion: boolean) => {
    let nextRaw = extractRaw(mask, text, uppercase);
    let k = extractRaw(mask, text.slice(0, caret), uppercase).length;
    // Backspacing over a literal leaves the raw value unchanged — the intent
    // was to delete, so remove the accepted char just before the caret.
    if (deletion && nextRaw === raw && raw.length > 0) {
      nextRaw = raw.slice(0, Math.max(0, k - 1)) + raw.slice(k);
      k = Math.max(0, k - 1);
    }
    const nextFormatted = applyMask(mask, nextRaw);
    pendingCaret.current = caretAfter(mask, nextFormatted, k);
    if (valueProp === undefined) setUncontrolled(nextRaw);
    onValueChange?.(nextRaw, nextFormatted);
  };

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    if (composing.current) return;
    const input = event.target;
    processText(
      input.value,
      input.selectionStart ?? input.value.length,
      input.value.length < formatted.length,
    );
  };

  const ghost = hint ?? defaultHint(mask);
  const ghostVisible = showGhost && !disabled && formatted.length < ghost.length;

  return (
    <div
      data-slot="masked-input"
      className={cn("relative w-full min-w-0", className)}
    >
      <style href="paragon-masked-input" precedence="paragon">{`
        @keyframes paragon-masked-check-in {
          from { opacity: 0; scale: 0.5; filter: blur(2px); }
        }
        @media (prefers-reduced-motion: reduce) {
          .paragon-masked-check { animation: none !important; }
        }
      `}</style>

      {/* Ghost pattern, aligned via an invisible copy of the typed prefix. */}
      {ghostVisible && (
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 flex items-center overflow-hidden px-3 text-sm whitespace-pre"
        >
          <span className="invisible tabular-nums">{formatted}</span>
          <span className="text-muted-foreground/50 tabular-nums">
            {ghost.slice(formatted.length)}
          </span>
        </div>
      )}

      <input
        ref={inputRef}
        type="text"
        value={formatted}
        onChange={handleChange}
        onCompositionStart={() => {
          composing.current = true;
        }}
        onCompositionEnd={(event) => {
          composing.current = false;
          const input = event.currentTarget;
          processText(
            input.value,
            input.selectionStart ?? input.value.length,
            false,
          );
        }}
        placeholder={ghostVisible ? undefined : placeholder}
        disabled={disabled}
        autoComplete="off"
        spellCheck={false}
        className={cn(
          "h-9 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 text-sm text-foreground tabular-nums",
          "transition-[border-color,box-shadow] duration-150 ease-out",
          "placeholder:text-muted-foreground",
          "outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
          "disabled:cursor-not-allowed disabled:opacity-50",
          complete && "pr-8",
        )}
        {...props}
      />

      {complete && (
        <Check
          aria-hidden
          className="paragon-masked-check pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-success"
          style={{
            animation: "paragon-masked-check-in 200ms var(--ease-out) both",
          }}
        />
      )}
    </div>
  );
}