# 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.

```html
<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>
```

`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.

```ts
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;
}
```

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.

```html
<div id="vin-status" role="status" aria-live="polite" aria-atomic="true"></div>
```

```ts
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;
  }
}
```

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.

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

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)

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

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](https://free-vin-lookup.com/), a free VIN decode based on NHTSA data.

Originally published on DEV: https://dev.to/vin_lookup_8dbd4710f77e9e/accessible-vin-input-labels-errors-and-screen-reader-announcements-boj

