DEV Community

Vin Lookup
Vin Lookup

Posted on

Accessible VIN Input Labels, Errors, and Screen Reader Announcements

A VIN form that only looks polished in a mouse-driven browser is unfinished. Screen reader users, keyboard-only users, and people who zoom text need the same clarity you give a sighted clicker: a real label, a predictable error, and a spoken confirmation when decode finishes. This post covers practical HTML and TypeScript patterns for a free VIN lookup field -- not a full WCAG audit, but the controls that prevent silent failure.

Label the control, not the placeholder

Placeholders disappear on type and are easy to miss. Use a visible <label for="..."> (or wrap the input) and keep placeholder text as an example only.

<label for="vin-input">Vehicle Identification Number (VIN)</label>
<input
  id="vin-input"
  name="vin"
  inputmode="text"
  autocomplete="off"
  spellcheck="false"
  maxlength="17"
  aria-describedby="vin-hint vin-error"
  aria-invalid="false"
/>
<p id="vin-hint">17 characters. Letters I, O, and Q are not used.</p>
<p id="vin-error" role="alert" hidden></p>
Enter fullscreen mode Exit fullscreen mode

aria-describedby wires hint and error into the accessible name/description computation. Keep IDs stable so you do not break associations when React remounts.

Errors must be text, not color alone

Red borders fail color-blind users and often fail screen readers if you never update text. When validation fails, set aria-invalid="true", reveal the alert node, and put the reason in words.

export type VinFieldState = {
  value: string;
  error: string | null;
  decoding: boolean;
};

export function applyVinError(
  input: HTMLInputElement,
  errorEl: HTMLElement,
  message: string | null,
): void {
  if (message) {
    input.setAttribute("aria-invalid", "true");
    errorEl.hidden = false;
    errorEl.textContent = message;
  } else {
    input.setAttribute("aria-invalid", "false");
    errorEl.hidden = true;
    errorEl.textContent = "";
  }
}

export function validateVinCharset(raw: string): string | null {
  const v = raw.trim().toUpperCase();
  if (v.length === 0) return "Enter a 17-character VIN.";
  if (v.length !== 17) return `VIN must be 17 characters (now ${v.length}).`;
  if (/[IOQ]/.test(v)) return "VIN cannot contain the letters I, O, or Q.";
  if (!/^[A-HJ-NPR-Z0-9]{17}$/.test(v)) {
    return "VIN may only use A-H, J-N, P, R-Z, and digits 0-9.";
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Prefer specific messages ("16 characters") over "Invalid VIN." Specificity reduces repeat attempts and helps people who cannot see the counter glyph next to the box.

Live regions for decode progress

While the request runs, sighted users see a spinner. Announce the same state with a polite live region so assistive tech is not left guessing.

<div id="vin-status" role="status" aria-live="polite" aria-atomic="true"></div>
Enter fullscreen mode Exit fullscreen mode
export function setDecodeStatus(
  statusEl: HTMLElement,
  phase: "idle" | "decoding" | "done" | "failed",
  detail?: string,
): void {
  switch (phase) {
    case "idle":
      statusEl.textContent = "";
      break;
    case "decoding":
      statusEl.textContent = "Decoding VIN, please wait.";
      break;
    case "done":
      statusEl.textContent = detail ?? "Decode complete.";
      break;
    case "failed":
      statusEl.textContent = detail ?? "Decode failed. See the error above.";
      break;
  }
}
Enter fullscreen mode Exit fullscreen mode

Use role="status" / aria-live="polite" for progress. Reserve role="alert" for the persistent field error so you do not double-shout every keystroke. Update the status on phase changes, not on every character.

Focus management after submit

If the user activates Decode and validation fails, move focus to the input (or to the error if you use a summary). If decode succeeds, move focus to the results heading so the next Tab cycle lands in the data, not back on the button forever.

export function focusAfterDecode(opts: {
  ok: boolean;
  input: HTMLInputElement;
  resultsHeading: HTMLElement | null;
}): void {
  if (!opts.ok) {
    opts.input.focus();
    return;
  }
  opts.resultsHeading?.focus();
}
Enter fullscreen mode Exit fullscreen mode

Give the results <h2> a tabindex="-1" so it can receive programmatic focus without entering the Tab order permanently.

Keyboard and contrast basics

  • The Decode button must be a real <button type="submit">, not a clickable <div>.
  • Enter in the input should submit the form.
  • Do not trap focus inside decorative modals for a simple decode.
  • Keep label/input contrast and error text contrast at or above WCAG AA for your theme (including dark mode).

These sound obvious until a design system ships a "pill" that is only a styled span.

TypeScript UI glue (framework-agnostic)

export function onVinSubmit(state: VinFieldState): VinFieldState {
  const error = validateVinCharset(state.value);
  if (error) return { ...state, error, decoding: false };
  return { ...state, error: null, decoding: true };
}
Enter fullscreen mode Exit fullscreen mode

Wire onVinSubmit to your form handler, then call applyVinError and setDecodeStatus from the resulting state. Keeping validation pure makes it easy to unit test messages without a browser.

What not to do

  • Do not announce "character 14 of 17" on every key unless users opt into a verbose mode -- it is noisy.
  • Do not put the only error inside a toast that disappears in two seconds.
  • Do not rely on title tooltips as the label.
  • Do not clear the input on failed decode; people need to edit what they typed.

Takeaway

Accessible VIN entry is labeled inputs, textual errors with aria-invalid, polite status announcements during decode, and focus that follows the task. The TypeScript stays small: validate to a string message, mirror that message into the DOM, and announce phase changes. Ship those before you polish the spinner animation -- more people will finish a lookup.

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

Top comments (0)