Custom Validity Messages: Implementation & UX Patterns
Custom validity messages let you replace the browser’s terse, inconsistent default error strings with context-aware, localized, business-rule-driven feedback by driving the customError flag through setCustomValidity() and reading back validationMessage. Done well, this gives you predictable wording across Chromium, WebKit, and Gecko while keeping the entire native Constraint Validation pipeline intact.
This guide sits inside the broader Mastering HTML5 Native Form Validation approach and builds directly on the Constraint Validation API Deep Dive: you augment native constraints rather than bypassing them. The house pattern remains a <form novalidate> whose submit handler calls checkValidity() then reportValidity(), with custom messages layered on top through a disciplined set-then-clear lifecycle.
customError flag never auto-clears — every interaction must reset it before any new message is applied.Prerequisites
| Requirement | Why | Check |
|---|---|---|
Stable id on each input |
Wire aria-describedby to a message container |
Unique id per control |
novalidate on the form |
Suppress native popups so your strings render in your own UI | form.noValidate |
| Native constraints present | Custom messages should augment, not replace, required/pattern/type |
Inspect attributes |
| A clear-then-set handler | customError never auto-clears |
input listener resets first |
| A message registry | Centralize strings for i18n and interpolation | Module-level map |
Core Implementation Patterns
The lifecycle revolves around two members: setCustomValidity(message), which sets customError and stores the string, and the read-only validationMessage, which reflects whatever error string is currently active (native or custom). Bind validation to input (debounced), blur, and submit. Validating on every keystroke causes layout thrashing; debounce to 300–500ms and defer visible feedback to meaningful state changes, exactly as the Constraint Validation API Deep Dive recommends.
// utils/debounce.ts
export function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number,
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
// validation/core.ts
export class CustomMessageValidator {
private form: HTMLFormElement;
private dirty = new Set<HTMLInputElement>();
constructor(formId: string) {
const form = document.getElementById(formId);
if (!(form instanceof HTMLFormElement)) throw new Error(`Form #${formId} not found`);
this.form = form;
this.bind();
}
private bind(): void {
this.form.addEventListener(
'input',
debounce((e: Event) => this.validateField(e.target as HTMLInputElement), 300),
);
this.form.addEventListener('blur', (e) => this.validateField(e.target as HTMLInputElement, true), true);
this.form.addEventListener('submit', (e: SubmitEvent) => {
if (!this.form.checkValidity()) {
e.preventDefault();
this.form.reportValidity();
}
});
}
private validateField(input: HTMLInputElement, force = false): void {
if (!(input instanceof HTMLInputElement)) return;
if (!force && !this.dirty.has(input)) {
this.dirty.add(input);
return; // Wait for a second interaction before surfacing errors.
}
input.setCustomValidity(''); // CRITICAL: clear before re-evaluating.
if (!input.checkValidity()) {
input.setCustomValidity(resolveMessage(input));
}
}
}
Dynamic Message Generation from ValidityState Flags
Hardcoded strings block internationalization. Instead, inspect which ValidityState flag is active and look the message up in a registry that supports interpolation. Reading individual flags is the subject of reading ValidityState flags for granular errors; here we map each one to a template.
type ConstraintFlag = keyof Omit<ValidityState, 'customError' | 'valid'>;
const ERROR_TEMPLATES: Record<ConstraintFlag, string> = {
valueMissing: 'This field is required.',
typeMismatch: 'Please enter a valid format.',
patternMismatch: 'Input does not match the required pattern.',
tooShort: 'Minimum length is {min} characters.',
tooLong: 'Maximum length is {max} characters.',
rangeUnderflow: 'Value must be at least {min}.',
rangeOverflow: 'Value cannot exceed {max}.',
stepMismatch: 'Value must be a multiple of {step}.',
badInput: 'The browser cannot parse this value.',
};
export function resolveMessage(input: HTMLInputElement): string {
const v = input.validity;
const flag = (Object.keys(ERROR_TEMPLATES) as ConstraintFlag[]).find((f) => v[f]);
if (!flag) return '';
return ERROR_TEMPLATES[flag]
.replace('{min}', String(input.minLength > 0 ? input.minLength : input.min))
.replace('{max}', String(input.maxLength > 0 ? input.maxLength : input.max))
.replace('{step}', input.step || '');
}
State-Driven Feedback Timing
Track pristine/dirty/valid so errors never fire before the user has engaged a field. Couple state with checkValidity() for silent real-time checks and reserve reportValidity() for explicit actions — the precise contrast is covered in checkValidity vs reportValidity differences.
interface FieldState {
pristine: boolean;
valid: boolean;
}
class StateTracker {
private states = new Map<HTMLInputElement, FieldState>();
update(input: HTMLInputElement, isValid: boolean): void {
const prev = this.states.get(input) ?? { pristine: true, valid: true };
const next: FieldState = { pristine: false, valid: isValid };
this.states.set(input, next);
// Surface ARIA only once the field is no longer pristine.
input.setAttribute('aria-invalid', String(!isValid));
}
}
Input-Specific Validation Strategies
Each HTML5 input type triggers distinct flags: type="email" sets typeMismatch, type="number" sets badInput on non-numeric characters, and pattern sets patternMismatch. Custom messages should map these flags to precise guidance without bypassing the underlying check. Always confirm semantic alignment against HTML5 Input Types & Attributes.
Type-Aware Messages
function typeSpecificMessage(input: HTMLInputElement): string {
if (!input.validity.typeMismatch) return '';
switch (input.type) {
case 'email':
return 'Enter a valid email address (e.g., user@example.com).';
case 'url':
return 'Include a protocol (https://) and a valid domain.';
case 'tel':
return 'Use digits, spaces, or hyphens only — no letters.';
default:
return 'Format does not match the expected type.';
}
}
Pattern-Aware Messages
The patternMismatch flag fires when a value fails the pattern regex, and the browser’s default (“Match the requested format”) is uninformative. Translate known patterns into actionable hints; the HTML5 pattern attribute regex examples catalogue lists the common ones.
function patternMessage(input: HTMLInputElement): string {
if (!input.validity.patternMismatch) return '';
const p = input.pattern;
if (p.startsWith('^[A-Z]')) return 'Must start with an uppercase letter.';
if (p.includes('\\d{4}')) return 'Requires exactly 4 digits.';
return 'Please match the requested format.';
}
Advanced UX & Edge Case Management
Clearing Stale Validation State
The single most common defect: failing to reset customError when a user corrects the field, which leaves it permanently invalid and blocks submission. The disciplined fix — clear unconditionally, reapply only on failure — is the core of how to use setCustomValidity correctly.
function clearThenValidate(input: HTMLInputElement): boolean {
input.setCustomValidity(''); // 1. Always clear.
const isValid = input.checkValidity(); // 2. Re-run native checks.
if (!isValid) {
input.setCustomValidity(resolveMessage(input)); // 3. Reapply only on failure.
}
return isValid;
}
Multi-Field Dependency Validation
Cross-field rules — password confirmation, date ranges, dependent addresses — need a shared context to avoid circular loops. Drive validation from a single source field, clearing both participants first. This is the bridge to the broader cross-field validation strategies, including cross-field password confirmation logic.
function validatePasswordMatch(password: HTMLInputElement, confirm: HTMLInputElement): void {
password.setCustomValidity('');
confirm.setCustomValidity('');
if (confirm.value && password.value !== confirm.value) {
confirm.setCustomValidity('Passwords do not match.');
}
}
confirmInput.addEventListener('input', () => validatePasswordMatch(passwordInput, confirmInput));
Accessibility & Screen Reader Integration
setCustomValidity() does not touch ARIA. Relying on native tooltips alone violates WCAG 4.1.3 (Status Messages) and 3.3.1 (Error Identification). Associate each message via aria-describedby and place it in a live region, following the patterns in UX Patterns & Error State Design and inline error messaging strategies.
function renderAccessibleError(input: HTMLInputElement): void {
const errorId = `${input.id}-error`;
let errorEl = document.getElementById(errorId);
if (!errorEl) {
errorEl = document.createElement('div');
errorEl.id = errorId;
errorEl.setAttribute('role', 'status');
errorEl.setAttribute('aria-live', 'polite');
input.insertAdjacentElement('afterend', errorEl);
}
if (input.validity.valid) {
input.removeAttribute('aria-invalid');
input.removeAttribute('aria-describedby');
errorEl.textContent = '';
} else {
input.setAttribute('aria-invalid', 'true');
input.setAttribute('aria-describedby', errorId);
errorEl.textContent = input.validationMessage;
}
}
Common Gotchas
1. Setting a message without ever clearing it. The field locks. Always setCustomValidity('') at the top of every validation pass.
2. Overriding a native failure. Calling setCustomValidity() before checking validity can mask a valueMissing or patternMismatch the browser already detected. Inspect the flags first and let native constraints win unless you have a specific business reason to override.
// ✅ Let native constraints surface before adding custom rules.
input.setCustomValidity('');
if (input.validity.valueMissing || input.validity.patternMismatch) return;
// ...custom cross-field logic here...
3. Showing a loading state by setting a message. A non-empty string immediately marks the field invalid. For async checks, indicate progress with aria-busy or a spinner — never a custom message — and only set the result string once the request resolves, per asynchronous server checks.
Browser Compatibility
| Capability | Chrome/Edge | Firefox | Safari | Mobile Safari |
|---|---|---|---|---|
setCustomValidity() |
Full | Full | Full | Full |
validationMessage reflects custom string |
Full | Full | Full | Full |
| Native tooltip rendering of custom text | Consistent | Minor clipping on long text | Delayed firing | Delayed |
:user-invalid styling hook |
Full | Full | Full (recent) | Full (recent) |
| Live-region announcement of custom error | Yes | Yes | Delayed | Delayed |
Because native tooltip rendering diverges most on Safari and Firefox, the most consistent result comes from suppressing native UI with novalidate and rendering validationMessage into your own aria-live container.
Frequently Asked Questions
Why does my field stay invalid after the user fixes it?
The customError flag never clears itself. If you set a message but never call setCustomValidity('') on subsequent input, the field stays invalid forever. The fix is to clear unconditionally at the start of every validation pass and reapply the message only when a check actually fails.
Can I localize the native default messages?
Not directly — the browser localizes its built-in strings to the user agent's language, which you cannot override. To control wording and locale yourself, detect the active ValidityState flag, look up your own translated template, and feed it to setCustomValidity(). Suppress native popups with novalidate so only your strings appear.
Should I use a custom message for asynchronous availability checks?
Only for the final result, not the loading phase. A non-empty setCustomValidity() string immediately marks the field invalid, so showing "checking…" that way would block submission mid-request. Indicate progress with aria-busy or a spinner, then set the result message (or clear it) once the request resolves.
Does setCustomValidity() replace native constraints?
No — it adds a parallel customError flag on top of the native ones. If you set a custom message while valueMissing is also true, the custom string is what surfaces, but both conditions still register in ValidityState. Best practice is to check native flags first and only add custom messages for rules the browser cannot express.
How do I make screen readers announce a custom message?
setCustomValidity() does not update ARIA. Mirror validationMessage into a container with role="status" or aria-live="polite", set aria-invalid="true" on the input, and link them with aria-describedby. That satisfies WCAG 3.3.1 and 4.1.3 even when native tooltips are suppressed.
Related Guides
- How to Use setCustomValidity Correctly — the strict clear-then-set lifecycle in depth.
- Constraint Validation API Deep Dive — the flags and methods custom messages build on.
- checkValidity vs reportValidity Differences — when your custom-message UI surfaces.
- HTML5 Input Types & Attributes — the constraints whose flags you translate into messages.
- Cross-Field Password Confirmation Logic — a canonical custom-message use case.
← Back to Mastering HTML5 Native Form Validation