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>
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;
}
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>
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.
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)
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
titletooltips 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)