DEV Community

Vin Lookup
Vin Lookup

Posted on

Why Input Masks Can Hurt VIN Paste UX More Than They Help

VIN fields invite input masks: forced uppercase on every keystroke, auto-inserted spaces every few characters, blocking of I/O/Q mid-type, and caret jumps that "help" the user stay on a 17-character path. On paper that looks like quality. In practice, paste-heavy VIN UX often gets worse because masks fight the clipboard, mobile keyboards, and notes-app fills.

This post argues for validate-on-normalize over mask-on-keypress for VIN entry -- with a small TypeScript pattern that keeps feedback honest without mangling paste.

What users actually do

Most successful VIN lookups are not typed character by character from a windshield. They are:

  • Pasted from a listing page
  • Autofilled from a notes app
  • Scanned via OCR and inserted as a burst
  • Copied from an email with trailing spaces or lowercase letters

A mask that rewrites value on every input event will often:

  1. Drop characters when the mask expects separators the clipboard did not include
  2. Move the caret so a second paste overwrites half the VIN
  3. Reject legal keystrokes during composition on mobile (especially with autocorrect)
  4. Show a false "invalid" flash while the paste is still settling

Blocking I, O, and Q is correct for the final VIN charset rule. Doing it as a keydown filter during paste can swallow parts of a good 17-character string if the browser fires odd event sequences.

Prefer normalize after commit

Keep the visible input permissive while typing. On paste, blur, or submit, run one normalizer: trim, strip invisible whitespace, uppercase, remove separators, then validate length and charset.

const FORBIDDEN = /[IOQ]/i;

export type VinNormalizeResult =
  | { ok: true; vin: string }
  | { ok: false; reason: string; cleaned: string };

export function normalizeVinInput(raw: string): VinNormalizeResult {
  const cleaned = raw
    .normalize("NFKC")
    .replace(/[\u200B-\u200D\uFEFF]/g, "")
    .trim()
    .toUpperCase()
    .replace(/[\s\-._]/g, "");

  if (cleaned.length === 0) {
    return { ok: false, reason: "Enter a 17-character VIN.", cleaned };
  }
  if (cleaned.length !== 17) {
    return {
      ok: false,
      reason: `VIN must be 17 characters (got ${cleaned.length}).`,
      cleaned,
    };
  }
  if (FORBIDDEN.test(cleaned) || /[^A-HJ-NPR-Z0-9]/.test(cleaned)) {
    return {
      ok: false,
      reason: "VIN uses letters A-H, J-N, P-R, T-Z and digits only (no I, O, Q).",
      cleaned,
    };
  }
  return { ok: true, vin: cleaned };
}

export function onVinPaste(
  pasted: string,
  setValue: (v: string) => void,
  setError: (e: string | null) => void,
): void {
  const result = normalizeVinInput(pasted);
  setValue(result.ok ? result.vin : result.cleaned);
  setError(result.ok ? null : result.reason);
}
Enter fullscreen mode Exit fullscreen mode

Show the cleaned value after paste so the user sees what you will send. Do not silently re-insert spaces for "readability" unless a separate display formatter never writes back into the controlled input value.

Masks that still make sense

Not every constraint is hostile:

  • maxLength={17} on the controlled value after separators are stripped is fine
  • inputMode="text" / autoCapitalize="characters" as soft hints on mobile is fine
  • A live character counter (12 / 17) is fine if it counts the normalized form, not mask decoration
  • Disabling submit until normalize succeeds is fine

What hurts is mutating caret position and inserting decorative characters into the same string the form submits.

Paste-first event handling

On paste, prefer clipboardData.getData("text"), run normalizeVinInput, then preventDefault only if you replace the selection with the cleaned VIN. Avoid stacking a mask library's onChange that runs again and re-spaces the value.

export function handlePasteEvent(
  e: { clipboardData: { getData: (t: string) => string }; preventDefault: () => void },
  setValue: (v: string) => void,
  setError: (e: string | null) => void,
): void {
  const text = e.clipboardData.getData("text");
  if (!text) return;
  e.preventDefault();
  onVinPaste(text, setValue, setError);
}
Enter fullscreen mode Exit fullscreen mode

For typed input, update state with the raw field value (or lightly uppercased) and defer hard errors until blur or submit so mid-string edits do not scream on every key.

Accessibility and honesty

Announce errors with aria-live after normalize fails -- not on every keystroke. Associate aria-describedby with the charset and length rules. Never claim "we fixed your VIN" if you only uppercased; say "We removed spaces and uppercased the paste" when you mutate.

What to test

Automated checks should paste messy fixtures, not only type clean characters:

  • Lowercase VIN with spaces every four characters
  • VIN wrapped in quotes or a leading label like VIN:
  • Zero-width spaces between characters
  • A 16-character string and an 18-character string
  • A string containing I or O that must fail after normalize

Assert the controlled input ends with the cleaned 17-character form on success, and that decorative spaces never reappear in the submitted payload. If a mask library is already in the tree, add a regression that paste does not shrink a valid VIN.

Takeaway

VIN UX is paste-first. Aggressive input masks optimize for the rare character-by-character typer and punish everyone else. Normalize and validate on commit, keep the controlled value free of decorative separators, and reserve charset rules for the cleaned string. Your free lookup form will accept real clipboard VINs without fighting the caret.

I maintain VIN Lookup, a free VIN decode based on NHTSA data.

Top comments (0)