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.

setCustomValidity and the customError flag lifecycle A field starts valid. setCustomValidity with a non-empty string sets customError true and blocks submission. setCustomValidity with an empty string clears it back to valid. Every input event must clear first, then conditionally reapply. valid customError = false invalid customError = true submission blocked set('message') set('') on every input: setCustomValidity('') FIRST then reapply only when a rule fails
The 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.

Why the customError Flag Is Sticky by Design

It is worth understanding why the specification makes customError persist rather than treating this as an oversight. The Constraint Validation API is deliberately declarative for the built-in constraints — required, minlength, pattern, and friends re-evaluate automatically on every value mutation because the browser owns both the rule and the current value. Custom errors invert that ownership: you supply an opaque string derived from logic the browser cannot see, so it has no way to know when your rule stops applying. The only safe default is to hold the last decision you communicated until you communicate a new one.

That design has a concrete consequence for reads. When you query input.validity.valid, the result is the logical AND of every native flag and customError. A field can satisfy every native constraint yet still report valid === false purely because a stale custom string was never cleared. This is why the clear-then-set discipline is not merely a convenience — it is the only mechanism that keeps the composite valid getter honest.

The same reasoning explains an asymmetry that trips people up: setting a native constraint attribute (say, toggling required off) does recompute validity immediately, but there is no equivalent attribute for custom errors. setCustomValidity('') is the entire public surface for retraction. Treat it as you would a manually managed subscription — every path that could change the field’s meaning must run through it.

Batching setCustomValidity Calls to Avoid Layout Thrash

On large forms, the hidden cost of custom messages is not the string assignment itself but what it triggers. Any call to setCustomValidity(), checkValidity(), or reportValidity() can force the browser to flush pending style and layout work, because the :user-invalid and :invalid pseudo-classes may change and the anchored tooltip position must be recomputed. Calling these in a tight loop over dozens of fields — for example, revalidating an entire section when a locale switch swaps every template — produces a visible stutter.

The fix is to separate the decision phase from the commit phase. Compute every field’s target message first, reading only the properties you need, then write all the custom validity strings in a second pass, and finally call reportValidity() exactly once. Batching this way collapses many potential reflows into one.

interface Verdict {
  input: HTMLInputElement;
  message: string; // '' means the field is valid
}

/**
 * Two-phase revalidation. Phase 1 is read-only and never mutates the DOM;
 * phase 2 writes every custom string, then surfaces the UI a single time.
 */
export function revalidateSection(
  fields: readonly HTMLInputElement[],
  resolve: (el: HTMLInputElement) => string,
): boolean {
  // Phase 1 — decide. Clearing first keeps checkValidity() honest, but we
  // stash the verdict instead of reporting it, so no tooltip repositions yet.
  const verdicts: Verdict[] = fields.map((input) => {
    input.setCustomValidity('');
    const message = input.checkValidity() ? '' : resolve(input);
    return { input, message };
  });

  // Phase 2 — commit. Write every string in one burst.
  let allValid = true;
  for (const { input, message } of verdicts) {
    input.setCustomValidity(message);
    input.setAttribute('aria-invalid', String(Boolean(message)));
    if (message) allValid = false;
  }

  // Surface the aggregate result exactly once — a single reflow.
  if (!allValid) fields[0]?.form?.reportValidity();
  return allValid;
}

The subtlety is that phase 1 still calls setCustomValidity('') before checkValidity(), because a leftover custom string would otherwise poison the native check. What the batching buys you is deferring the expensive reportValidity() — the call that focuses, scrolls, and paints — until the decisions are final.

Composing Custom Messages with the :user-invalid Pseudo-Class

Modern engines expose :user-invalid, which only matches after the user has meaningfully interacted with a control and left it invalid. This is the CSS-side answer to the pristine/dirty tracking you would otherwise hand-roll, and it pairs cleanly with custom messages because customError participates in it exactly like a native failure. Styling the container off :user-invalid rather than :invalid means your custom string and its red border appear on the same schedule the user expects — after blur or submit, never on an untouched field.

// The message element mirrors validationMessage; CSS decides visibility.
function syncMessageElement(input: HTMLInputElement): void {
  const el = document.getElementById(`${input.id}-error`);
  if (!el) return;
  // Always keep the text current so :user-invalid can reveal it instantly,
  // without a JavaScript round-trip on the exact frame the field blurs.
  el.textContent = input.validationMessage;
}
/* The paragraph is present in the DOM but hidden until the engine
   decides the field is user-invalid. No JS toggling of display. */
.field-error { display: none; color: #b91c1c; }
input:user-invalid ~ .field-error { display: block; }
input:user-invalid { border-color: #ef4444; }

This split — JavaScript owns the text, CSS owns the timing — removes a whole class of bugs where a message flashes before the user has typed anything. For engines without :user-invalid, fall back to a .was-touched class toggled on blur; the selector list degrades gracefully because unknown pseudo-classes invalidate only their own compound selector, not the rule.

Guarding Against Untrusted Message Content

Custom messages are frequently assembled from values the user or a server controls — an echoed field value (“{value} is already taken”), a server-supplied reason string, or an interpolated template. Because validationMessage is exposed to assistive technology and, in your own UI, written into the DOM, treat that string as untrusted data. setCustomValidity() itself is safe: the browser renders its argument as plain text in the native tooltip, with no markup parsing. The risk lives entirely in your rendering path.

// SAFE: textContent never parses markup, so a value like "<img onerror=…>"
// is shown literally. This is the correct sink for validationMessage.
errorEl.textContent = input.validationMessage;

// UNSAFE: innerHTML would execute injected markup from an echoed value.
// Never route a custom message — or any server reason string — through it.
// errorEl.innerHTML = input.validationMessage;

Keep interpolation to plain concatenation into textContent, and if a rule must quote user input back, cap its length before embedding it so a pathological value cannot blow out your layout or drown the actionable part of the sentence. The message exists to tell the user what to fix, so a truncated echo followed by clear guidance beats a faithful but unreadable one.

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.

Is it safe to embed a user-supplied value inside a custom message?

Passing it to setCustomValidity() is safe because the native tooltip renders the argument as plain text with no markup parsing. The risk is in your own UI: if you mirror validationMessage into the DOM, write it with textContent, never innerHTML, so an echoed value like <img onerror=…> is shown literally rather than executed. Cap the echoed length too, so a pathological value cannot swamp the actionable guidance.

Should I style errors with :invalid or :user-invalid?

Prefer :user-invalid. It only matches after the user has interacted with a control and left it invalid, so your custom message and its error styling appear on the schedule people expect — after blur or submit — instead of flashing on a field they have not touched. customError participates in :user-invalid exactly like a native failure, so custom-rule errors get the same well-timed reveal. For older engines, fall back to a class toggled on blur.

← Back to Mastering HTML5 Native Form Validation

Explore This Section