oli's profile picture
Article7.9 minute read

Better native form validation

All but the simplest forms need some validation to help users enter the right information. I usually want slightly more control than native HTML5 validation, but without re-implementing everything in JavaScript. I recently discovered a great technique for enhancing the browser's built-in validation.

Quick summary

If you just want the code here's the final CodePen. I turn off default validation with the form's novalidate attribute, then manually validate all fields with form.checkValidity(). This triggers an invalid event on each field, allowing me to mark each field as invalid and show the default browser validation message in a div.

Native isn't always better

I sometimes catch myself assuming that if the browser implemented it a feature must be usable/accessible/performant etc. Unfortunately this is not always the case. Whilst defaulting to built-in semantic HTML elements is usually a good idea, there are plenty of cases where this will let you down.

Dave Rupert recently documented lots of cases of innaccessible HTML. This is heartbreaking as someone who cares about and advocates for accessibility.

The same problem applies to native form validation. Adrian Roselli highlights several WCAG violations with the default browser behaviour.

Here is an incomplete list of problems:

  1. Some screen reader + browser combinations do not read out the validation message.
  2. required inputs are immediately announced as "invalid" before the user has tried to enter information.
  3. Default error indicators generally rely only on a coloured outline.
  4. We cannot style the validation message "bubble".

Let's see if we can do better.

Validation goals

I set out with a specific set of goals:

  1. Keep using HTML5 validation attributes (required, minLength, pattern etc).
  2. Don't re-implement stuff HTML5 attributes can do for us (no unnecessary JS).
  3. Default to using the browser's validation messages, but override with my own where needed.
  4. Add my own DOM elements containing validation messages so I can style them and expose them to assistive tech.

It turns out the Constraint Validation API provides all the tools we need.

Constraint Validation

This API provides access to and control of all the native validation features, but via JavaScript.

Validity state

The browser exposes the "validity state" of an input via the inputElement.validity property. The ValidityState interface has properties for each possible native validation error. It looks like this:

ValidityState {
  badInput: false,
  customError: false,
  patternMismatch: false,
  rangeOverflow: false,
  rangeUnderflow: false,
  stepMismatch: false,
  tooLong: false,
  tooShort: false,
  typeMismatch: false,
  valid: true,
  valueMissing: false
}

Only one of these properties can be true at a time. When the browser validates a field it will flip the property for the first constraint failure to true. If the input has no constraint failures the valid property will be set to true.

I'm not sure where the order comes from, but valueMissing appears to always come first. I guess there's no point validating more specific constraints when the user hasn't typed anything yet.

Preventing default validation

If we're going to implement our own validation we should stop the browser defaults showing up. We can do this by adding the novalidate attribute to the form element.

const form = document.querySelector("form");
form.setAttribute("novalidate", ""); // boolean attributes don't need a value

Now the browser will allow a form containing invalid values to be submitted, so we need to stop that happening.

Triggering validation

By default the browser validates all fields within a form when that form is submitted. We can mimic that behaviour with an onsubmit handler that calls formElement.checkValidity. This method will validate each field within the form and return true if there were no failures or false if any input is invalid.

Since the form should not submit with invalid values we can call event.preventDefault if we receive a false value.

form.addEventListener("submit", (event) => {
  const allValid = form.checkValidity();
  if (!allValid) {
    event.preventDefault();
  }
});

Detecting invalid fields

This is my favourite part. I always assumed I had to manually loop through all the fields and figure out which ones were invalid. I recently discovered that's not the case: the browser fires "invalid" events on each input that is validated and has a constraint failure.

We can add access each field in our form using form.elements, then attach an oninvalid listener to each.

const fields = Array.from(form.elements);
fields.forEach((field) => {
  field.addEventListener("invalid", () => {
    console.log(field.validity);
  });
});

Now when we submit our form and call checkValidity all invalid fields should log their validity state.

Validation messages

We want to put an element in the DOM containing a validation message for each invalid field. This element should be associated with the input so that its content is part of the input's accessible name.

That way when an assitive tech user focuses the input the validation message will be read out after the label. Scott O'Hara has a great guide on using the aria-describedby attribute to provide additional information about form fields.

Our final markup should look something like this:

<label for="email">
  Email
  <span aria-hidden="true">*</span>
</label>
<input type="email" id="email" aria-describedby="emailError" required />
<div id="emailError"></div>

Since we're currently just replicating the built-in validation we should re-use the message the browser provides. We can get this from the inputElement.validationMessage property.

We're enhancing the native browser validation here, so we should add the message element with JS. That way any user whose JS doesn't run will just get the normal slightly-less-good HTML5 validation.

fields.forEach((field) => {
  const errorBox = document.createElement("div");
  const errorId = field.id + "Error";
  errorBox.setAttribute("id", errorId);
  field.setAttribute("aria-describedby", errorId);
  field.insertAdjacentElement("afterend", errorBox);

  field.addEventListener("invalid", () => {
    errorBox.textContent = field.validationMessage;
    // e.g. "Please fill out this field"
  });
});

Communicating validity

We now have a functioning replica of the default validation. However there's no indication that a given field is invalid (other than the message appearing). We need to communicate this both visually and programmatically. It's usually a good idea to tie these things together so that the visual styles depend on the right programmatic attributes being set.

In this case we should set aria-invalid="true" for any field that fails validation. We should also set aria-invalid="false" on all the fields before they are validated. That way fields aren't invalid before the user has had a chance to enter any information.

fields.forEach((field) => {
  field.setAttribute("aria-invalid", false);
  // ...

  field.addEventListener("invalid", () => {
    // ...
    field.setAttribute("aria-invalid", true);
  });
});

We can now use this ARIA attribute as a styling hook:

[aria-invalid="true"] {
  border-color: firebrick;
}

You'll probably want more complex styles than this to make sure all types of field are correctly highlighted as invalid. E.g. it might be nice to show some kind of warning icon next to the field.

Fixing errors

Currently the field will never be marked as valid again. Even if the user fixes their entry the field will still have aria-invalid="true' and a visible error message. We can handle this in two ways: either re-validate as they type, or mark the field as valid as soon as the user changes it.

Clear errors

We can add an oninput handler that marks the field as valid again whenever the user changes the value.

fields.forEach((field) => {
  //...
  field.addEventListener("input", () => {
    field.setAttribute("aria-invalid", false);
    errorBox.textContent = "";
  });
});

Now as soon as the user edits the field it is marked as valid. It will get re-validated when the form is submitted, as before.

Re-validating

The browser re-validates invalid fields when the user types into them. This gives the user immediate feedback when they're trying to correct an invalid field. However I tend to think a constantly updating error beneath an input is distracting, so this might be something that you need to user test.

Form fields also have a checkValidity method. This behaves just like the form element's method, except it only triggers validation for this single field.

We check its validity, which will trigger an invalid event that updates the message if the field is invalid. If the field is valid we mark the input as valid and remove the error message.

fields.forEach((field) => {
  //...
  field.addEventListener("input", () => {
    const valid = field.checkValidity();
    if (valid) {
      field.setAttribute("aria-invalid", false);
      errorBox.textContent = "";
    }
  });
});

Validating before submit

This is optional but it can be helpful to validate a field as soon as the user is done filling it in. That way the user can immediately fix the error rather than filling out the entire form and attempting to submit before they know they made a mistake.

We can add an onblur listener that will fire when the user's focus leaves the field.

fields.forEach((field) => {
  //...
  field.addEventListener("blur", () => {
    field.checkValidity();
  });
});

Custom messages

Some of the built-in browser validation messages are unhelpful or confusing. It would be nice if we could override this for certain fields/validation states. We can write a getMessage function that checks the field's validity property and returns custom message string.

field.addEventListener("invalid", () => {
  const message = getMessage(field);
  errorBox.textContent = message || field.validationMessage;
});

function getMessage(field) {
  const validity = field.validity;
  if (validity.valueMissing) return `Please enter your ${field.id}`;
  if (validity.typeMismatch) return `Please enter a valid ${field.type}`;
}

You can create the messages however you like: dynamically using the element properties like this, or look them up in a big object where you define every single possible error for every input.

It's worth highlighting that the browser's default messages are translated, so make sure yours are too if you have a localised application.

Conclusion

You can play around with the final version in my CodePen example. It's not too much code, and since it's pretty generic you can copy/paste it for any form you like. Hopefully you'll go forth and write more usable and accessible forms 🚀.