Skip to main content

Progressively Enhanced Form Validation, Part 3: Validating a checkbox group

By Gerardo Rodriguez

Published on August 21st, 2023

Topics
Three icons. Icon 1: representing an invalid state, a fuscia circle shape with a white exclamation mark in the center. Icon 2: representing a valid state, a green circle shape with a white checkmark in the center. Icon 3: square representing a checkbox group.

Part 1 and Part 2 of this series explore the browser’s built-in HTML and CSS form validation features and how to progressively enhance the experience by layering JavaScript using the Constraint Validation API.

This article extends that exploration by focusing on a form validation use case that is not handled by the browser’s native validation features: a checkbox group.

Feel free to view the demo for this article as a reference. The source code is available on GitHub.

A checkbox group is a series of checkbox input types with the same name attribute value. For example, below is a simplified version of the “interests” checkbox group from the demo:

<fieldset>
  <legend>Interests <span>(required)</span></legend>
  <input id="coding" type="checkbox" name="interests" value="coding" />
  <label for="coding">Coding</label>
  <input id="music" type="checkbox" name="interests" value="music" />
  <label for="music">Music</label>
  <input id="art" type="checkbox" name="interests" value="art" />
  <label for="art">Art</label>
  <input id="sports" type="checkbox" name="interests" value="sports" />
  <label for="sports">Sports</label>
</fieldset>Code language: HTML, XML (xml)

Notice that a fieldset element wraps the checkboxes and includes a legend describing the group. It’s a good practice to wrap a group of checkboxes (or any related form elements) in a fieldset element as it improves semantics, accessibility, and visual organization.

A group of checkbox inputs, labelled "Interests (required)". The checkbox input options are Coding, Music, Art, and Sports. No checkboxes are selected.
An “interests” checkbox group requires JavaScript for validation.

Unlike a radio group, there isn’t a native HTML validation feature to mark a checkbox group as “required” (where at least one checkbox must be selected). Since we cannot validate using browser built-in features, we’ll need to add custom JavaScript.

For this demo, we’ll focus on the following:

  1. Implementing the real-time validation pattern: Validate when any checkbox within the group is checked/unchecked
  2. Implementing the late validation pattern: Validate when the group loses focus when keyboard tabbing through the interface
  3. Adding the validation logic: At least one interest must be selected from the group
  4. Toggling the checkbox visual validation state: Toggle is-invalid/is-valid CSS classes appropriately
  5. Adding an accessible custom error message: Show a custom error message when applicable
  6. Adding a group validation state icon and aria-invalid value: Individual icons per checkbox don’t make sense; consider accessibility
  7. Hooking into the existing form submit handler: Use the existing onSubmit handler flow
  8. Include the checkbox group in the “first invalid input” focus: Automatically set focus to the group when the form is submitted if it’s the first invalid “input”
  9. Selectively validate the checkbox group on page load: Handle if a checkbox is checked on page load

There may be room for improvement, but implementing the above requirements provides a solid, enhanced experience when JavaScript is available.

Let’s dive in!

Let’s start adding some JavaScript. We’ll begin by adding a function, validateInterestsCheckboxGroup, that we can call to validate the group of checkboxes (we’ll fill in this function’s logic shortly). The function will accept the form element as an argument:

/**
 * Validates the "interests" checkbox group.
 * Custom validation is required because checkbox group validation 
 * is not supported by the browser's built-in validation features.
 * @param {HTMLFormElement} formEl The form element
 * @return {boolean} Is the "interests" checkbox group valid?
 */
const validateInterestsCheckboxGroup = (formEl) => {
  // Validation logic will go here…
}
Code language: JavaScript (javascript)

Now let’s set up the change event listener to hook up real-time validation (a live validation pattern).

Input elements of type="checkbox" or type="radio" have inconsistent browser behavior when using the input event. Per MDN Web Docs, the change event is suggested instead for these input types.

We can add the change event listener code in the init function introduced in Part 2 so it can initialize with the rest of the form validation logic:

  • Select all inputs with a name value of “interests” (the checkboxes)
  • For each checkbox input, add a change event listener with validateInterestsCheckboxGroup as the callback function
  • Make sure to pass along the formEl as the argument
/**
 * Initialize validation setup
 */
const init = () => {
  const formEl = document.querySelector('#demo-form');

  // Existing code from Part 2 here…

  // Set up event listeners to validate the "interests" checkbox group.
  document
    .querySelectorAll('input[name="interests"]')
    .forEach((checkboxInputEl) => {
      // Updates the UI state for the checkbox group when checked/unchecked
      checkboxInputEl.addEventListener('change', () =>
        validateInterestsCheckboxGroup(formEl)
      );
    });
};

Code language: JavaScript (javascript)

As previously noted, carefully consider when adding real-time validation, as not all users appreciate live validation feedback. A group of checkboxes is a more appropriate use case for real-time validation since after a single action (press/click), the user is “done” checking or unchecking the input, unlike a text input where a single action (typing one character) may not complete the user’s full intent.

To test things are working, we can add a console.log in the validateInterestsCheckboxGroup function we created above:

const validateInterestsCheckboxGroup = (formEl) => {
  console.log('Validate the "interests" checkbox group');
}
Code language: JavaScript (javascript)
Selecting/unselecting any “interests” checkbox triggers the console.log message.

Fantastic, the correct wires are connected! This sets us up to validate the group when any checkboxes are checked or unchecked.

Per our requirements above, when navigating through the interface with a keyboard, the checkbox group should be validated when a user tabs out of the group. This live validation pattern is known as “late validation.”

Setting up the late validation pattern requires a touch of extra logic for a group of checkboxes. For example, if it were a single checkbox input, we’d want the validation to happen immediately when the single checkbox’s blur event fires. However, we only want the validation to run for a checkbox group when the focus has left the group.

The following logic gets us what we need:

  • Add a blur event to each of the checkbox inputs in the group
  • On blur, check the FocusEvent.relatedTarget to see if it is one of the checkboxes
  • If it is not one of the checkboxes, run the validation

For a blur event, the FocusEvent.relatedTarget is the element receiving focus (the EventTarget). In our case, we can use this to tell if the element receiving focus is or is not one of the “interests” checkbox inputs.

We can add the blur logic alongside the previously added change event listener:

const init = () => {
  const formEl = document.querySelector('#demo-form');

  // Existing code from Part 2 here…

  // Set up event listeners to validate the "interests" checkbox group.
  document
    .querySelectorAll('input[name="interests"]')
    .forEach((checkboxInputEl) => {
      // Updates the UI state for the checkbox group when checked/unchecked
      checkboxInputEl.addEventListener('change', () =>
        validateInterestsCheckboxGroup(formEl)
      );
      // Set up late validation for the checkbox group
      checkboxInputEl.addEventListener('blur', (event) => {
        // FocusEvent.relatedTarget is the element receiving focus.
        const activeEl = event.relatedTarget;
        // Validate only if the focus is not going to another checkbox.
        if (activeEl?.getAttribute('name') !== 'interests') {
          validateInterestsCheckboxGroup(formEl);
        }
      });
    });
};

Code language: JavaScript (javascript)

Excellent! That wasn’t terrible to figure out (props to Paul Hebert for pointing me to the FocusEvent.relatedTarget MDN docs 🙂).

Below, notice the console.log message prints only when the focus leaves the group of checkboxes (and not when tabbing between the checkbox inputs):

The checkbox group validation only runs when the focus leaves the group.

We can now add the code to validate the checkbox group inside the validateInterestsCheckboxGroup function. The logic will be as follows:

  • Use the getAll method from the FormData API to confirm at least one checkbox is checked
  • Use the result to return a boolean representing the “Is the checkbox group valid?” state

The FormData API provides a concise way to interact with field values from an HTML form. The API feels intuitive and is well-supported. For example, the FormData.getAll method returns an array of all the values for a given key, an excellent choice for a checkbox group.

Note: Input fields must have a name attribute to be retrieved by the FormData API.

Replace the console.log with the following logic:

const validateInterestsCheckboxGroup = (formEl) => {
  // Are any of the "interests" checkboxes checked? 
  // At least one is required.
  const formData = new FormData(formEl);
  const isValid = formData.getAll('interests').length > 0;

  // Return the validation state.
  return isValid;
}
Code language: JavaScript (javascript)

Not too bad! We now have the checkbox group logic to drive the rest of the validation experience.

With the validation logic in place, we can leverage it to provide visual feedback. In the validateInterestsCheckboxGroup function, we’ll want to:

  • Select all of the “interests” checkboxes
  • Reference the isValid boolean to toggle is-invalid/is-valid CSS classes for each checkbox
const validateInterestsCheckboxGroup = (formEl) => {
  // Code from above here…

  // Get all the "interests" checkboxes.
  const interestsCheckboxInputEls = document.querySelectorAll(
    'input[name="interests"]'
  );

  // Update the validation UI state for each checkbox.
  interestsCheckboxInputEls.forEach((checkboxInputEl) => {
    checkboxInputEl.classList.toggle('is-valid', isValid);
    checkboxInputEl.classList.toggle('is-invalid', !isValid);
  });

  // Return the validation state.
  return isValid;
}
Code language: JavaScript (javascript)

With that in place, the checkboxes’ visual validation state now updates. Hooray!

Below you can see the real-time validation pattern in action. Notice the checkbox border color changes between a “valid” (green) and “invalid” (red) state when checked/unchecked:

The checkboxes toggle their visual “valid”/”invalid” state when checked or unchecked.

The late validation pattern is now also updated visually. The border color for all of the checkboxes renders the “invalid” (red) state when the keyboard focus leaves the group if no checkbox was selected:

The border of the checkboxes in the group turns red when the focus leaves the group if no checkboxes are checked.

In Part 2, we created a more accessible experience by adding an aria-describedby attribute on the individual inputs pointing to the ID of their respective error message elements.

We need to use a different pattern for the checkbox group because we want to associate the error message with the group as a whole.

The pattern we’ll implement includes injecting the error message into the legend element. When using a screen reader, for example, the validation feedback will be incorporated when the screen reader reads the legend.

While this pattern is a bit more complicated, it was the most inclusive pattern I could find based on my research which also included reaching out to the web-a11y.slack.com community. Theoretically, the pattern could be simplified by only adding an aria-describedby attribute to the fieldset element, but unfortunately, there is an NVDA bug where the aria-describedby attribute is not respected for a checkbox group. A helpful resource for me was a great article by Tenon, “Accessible validation of checkbox and radiobutton groups,” where they explore various checkbox group validation patterns.

Let’s start with the HTML updates, which include adding some ARIA attributes:

  • In the fieldset element: Add an aria-required attribute to provide extra feedback when using assistive technologies like a screen reader
  • In the legend element: Add an aria-hidden attribute to the “(required)” text
    • This “required” text provides visual feedback but is redundant when using assistive technology devices

We’ll also be adding two empty error message elements; one that assistive technologies will pick up (visually hidden) and one for users who are sighted (visible but hidden from assistive technologies):

  • First empty error message element: Place it inside the legend
    • Visually hide the element via a visually-hidden CSS class
    • Includes a js-interests-legend-error CSS class to attach JavaScript logic
  • Second empty error message element: Place it below the last checkbox input
    • Has hidden attribute as the default state
    • Hidden from assistive technologies via aria-hidden attribute so duplicate error messages aren’t conveyed
    • Includes a js-interests-visual-error CSS class to attach JavaScript logic

Adding two error message elements may seem redundant, but there’s a good reason. We add the first empty error message element within the legend element so that assistive technologies, like screen readers, can associate the error message with the checkbox group. But that doesn’t match our visual design, so we visually hide it. The second empty error message element is added to match the visual design; we hide it from assistive technologies so the error message doesn’t get conveyed twice.

<fieldset aria-required="true">
  <legend>
    Interests <span aria-hidden="true">(required)</span>
    <span class="visually-hidden js-interests-legend-error">
      <!-- Text content set by JS -->
    </span>
  </legend>
  <div class="field-wrapper checkbox-field-wrapper">
    <!-- 
      Checkboxes and label elements here… 
    -->
    <p hidden aria-hidden="true" class=js-interests-visual-error">
      <!-- Text content set by JS -->
    </p>
  </div>
</fieldset>
Code language: HTML, XML (xml)

In the JavaScript, we can update the validateInterestsCheckboxGroup function as follows:

  • Get both empty error message elements via their js-* classes
  • Set the error message depending on the isValid boolean
  • Toggle the hidden attribute on the visible error element accordingly
const validateInterestsCheckboxGroup = (formEl) => {
  // Existing code from above here…

  // Get both the legend and visual error message elements.
  const legendErrorEl = document.querySelector('.js-interests-legend-error');
  const visualErrorEl = document.querySelector('.js-interests-visual-error');

  // Update the validation error message.
  const errorMsg = isValid ? '' : 'Select at least one interest.';

  // Set the error message for both the legend and the visual error.
  legendErrorEl.textContent = errorMsg;
  visualErrorEl.textContent = errorMsg;

  // Show/hide the visual error message depending on validity.
  visualErrorEl.hidden = isValid;

  // Return the validation state.
  return isValid;
}
Code language: JavaScript (javascript)

Wonderful! We now have an accessible validation error message. Below you can see the error message visually displayed:

The validation error message is shown when the checkbox group is in an “invalid” state.

If using a screen reader, in this example, VoiceOver with Safari on macOS, the validation feedback is included with the legend. Also, notice the “required” feedback provided by the aria-required attribute on the fieldset:

A screenshot of VoiceOver in Safari on macOS showing the legend of the checkbox group read along with validation feedback: "Interests, Select at least one interest, required, group".
When using a screen reader, the validation feedback is included when the legend is read.
Pictured is VoiceOver in Safari on macOS.

In Part 2, each individual input had its own “valid”/”invalid” state icon and aria-invalid attribute. We need to move those out onto the fieldset for a checkbox group.

Need a refresher on aria-invalid? See “What does aria-invalid do?” in my previous article.

Let’s add a js-* CSS class to the fieldset so we can reference it in our JavaScript code:

<fieldset 
  aria-required="true" 
  class="js-checkbox-fieldset"
>
Code language: HTML, XML (xml)

Then, in the validateInterestsCheckboxGroup function, we can add the following logic:

  • Get the fieldset element via the js-* CSS class
  • Depending on the isValid boolean, update:
    • The is-valid/is-invalid state classes to toggle a single “valid”/”invalid” group icon
    • The aria-invalid attribute
const validateInterestsCheckboxGroup = (formEl) => {
  // Existing code from above…

  // Get the fieldset element for the "interests" checkbox group.
  const checkboxFieldsetEl = document.querySelector('.js-checkbox-fieldset');

  // Need to place the validation state classes higher up to show
  // a validation state icon (one icon for the group of checkboxes).
  checkboxFieldsetEl.classList.toggle('is-valid', isValid);
  checkboxFieldsetEl.classList.toggle('is-invalid', !isValid);
  // Also update aria-invalid on the fieldset (convert to a string)
  checkboxFieldsetEl.setAttribute('aria-invalid', String(!isValid));

  // Return the validation state.
  return isValid;
}
Code language: JavaScript (javascript)

Sweet! We can see a single group validation state icon in action below:

Checking at least one checkbox from the group shows the “valid” icon for the fieldset group. Unchecking all checkboxes shows the “invalid” icon for the group.

When using VoiceOver with Safari on macOS, the aria-invalid attribute adds “invalid data” to the validation feedback for the group:

A screenshot of VoiceOver with Safari on macOS showing the "invalid" checkbox group state with VoiceOver reading "Interests, Select at least one interest., required, invalid data, group"
With the checkbox group in an “invalid” state, VoiceOver will add “invalid data” when the aria-invalid="true" is added to the fieldset.

In the previous article, we set up the logic for the form submit event. Adding the checkbox group validation into the existing submit flow will be relatively straightforward.

Just a moment ago, in the validateInterestsCheckboxGroup function, we added logic that stores and returns the validation state via the isValid boolean:

const validateInterestsCheckboxGroup = (formEl) => {
  // Existing code from above…

  // Return the validation state.
  return isValid;
}
Code language: JavaScript (javascript)

We can use the returned boolean value to include the checkbox group validation within the existing onSubmit flow. We can add the following logic to that flow:

  • Call the validateInterestsCheckboxGroup function and store the returned boolean value
  • Include the returned boolean value in the “Is the form valid” check
const onSubmit = (event) => {
  // Existing onSubmit code from Part 2…

  // Fields that cannot be validated with the Constraint Validation API need
  // to be validated manually. This includes the "interests" checkbox group.
  const isInterestsGroupValid = validateInterestsCheckboxGroup(formEl);
  // Prevent form submission if any of the validation checks fail.
  if (!isFormValid || !isInterestsGroupValid) {
    event.preventDefault();
  }
};
Code language: JavaScript (javascript)

And that’s it! Now, the checkbox group will also be validated when the form is submitted:

When the form is submitted, the checkbox group is validated.

Some of the work in the form submit logic introduced in Part 2 was to set focus on the first invalid input when the form is submitted. We’ll want to ensure the checkbox group is included in this query.

To do so, we’ll need to make another update to the existing onSubmit handler as follows:

  • Add a selector for the input in a fieldset that has the is-invalid state class (e.g., 'fieldset.is-invalid input')
const onSubmit = (event) => {
  // Existing onSubmit code from Part 2…

  // Set the focus to the first invalid input.
  const firstInvalidInputEl = formEl.querySelector(
    'input:invalid, fieldset.is-invalid input'
  );
  firstInvalidInputEl?.focus();
};
Code language: JavaScript (javascript)

Note: The order of operations matters here. The validateInterestsCheckboxGroup function must be called before attempting to query for fieldset.is-invalid input. Otherwise, the fieldset won’t have the is-invalid class to query by.

It’s almost like magic! Below, you can see the group’s first checkbox input receiving focus when no checkbox is selected and the form is submitted:

The first checkbox in the group receives focus when the form is submitted if the group is the first invalid input.

We are near the finish line! One last thing to do. Currently, the checkbox group validation will only happen when a checkbox input’s change, blur, or form submit events fire. There is one more case we should handle: What if a checkbox is checked and the browser reloads the page?

Ideally, if the page loads with at least one selection made, it should show the “valid” UI state. At the moment, nothing happens, and the checkbox group ends up in this unresolved state:

The "interests" checkbox group with "music" and "art" selected but the "valid" UI state is not rendered.
The “valid” checkbox group UI state should render when at least one checkbox is checked on page load.

We can resolve this by adding the following logic to the existing init function from Part 2:

  • Query for all “interests” inputs (the checkboxes) that have a :checked state
  • If any are checked, run the validateInterestsCheckboxGroup function so the “valid” UI state can render
/**
 * Initialize validation setup
 */
const init = () => {
  // Existing code from Part 2 here…

  // On page load, if a checkbox is checked, update the group's UI state
  const isInterestsGroupChecked =
    document.querySelectorAll('input[name="interests"]:checked').length > 0;
  if (isInterestsGroupChecked) {
    validateInterestsCheckboxGroup(formEl);
  }
};

Code language: JavaScript (javascript)

Easy peasy! Now, when the page loads with at least one checkbox selected, we see the “valid” UI state as expected:

The "interests" checkbox group with "music" and "art" selected with the "valid" UI state rendered.
The checkbox group “valid” UI state now renders on page load if at least one checkbox is selected.

We don’t want to run the validateInterestsCheckboxGroup function every time the validation code is initialized because if no checkboxes are checked, then the “invalid” UI state will render. This pattern, called premature validation, is not helpful and leads to a frustrating user experience.

Because we can always learn more and keep growing, I wanted to note a few opportunities to improve the user experience.

  • Localize the validation error message: Currently, the validation error message is hard-coded in English in JavaScript. Ideally, the message can be localized into different languages.
  • Remove or minimize the layout shift when validation error messages are displayed: When the error messages are displayed, there is a visual layout shift. This issue is not specific to the checkbox group, but it becomes more prominent (and annoying) as more fieldsets/inputs are added to the form.

That was fun! I’ll admit that the perfectionist in me almost gave up on finding an accessible validation solution for a checkbox group, but I’m glad I pushed through. Finding a solution feels good, even if not ideal, especially knowing the native HTML validation features leave us short.

Stick around for the following article, Part 4, where we explore using the Constraint Validation API’s ValidityState interface to help render custom validation error messages.

Until next time!

A special thank you to Juliette Alexandria, Adrian Roselli, and Joe Schunk, who provided me with feedback, resources, and confirmation for creating a more accessible checkbox group validation experience via the web-a11y.slack.com Slack community. 🙌🏽

I’ve got you! Listed below are all of the articles from the series: