Latest Post: Why <s>, <del>, and <ins> Don't Speak: Accessible Alternatives for Text-Level Semantics

How to Build Accessible Forms with HTML, CSS and JavaScript

Learn how to build accessible, user-friendly forms with HTML, CSS, and JavaScript. Step-by-step guide aligned with WCAG 2.2 accessibility standards.

9 min read
Illustration of an accessible web form on a blue background. The form includes two input fields, a textarea, and a submit button labeled 'Submit' with a paper plane icon. An accessibility icon is displayed in the top-right corner of the form.

Web forms are everywhere — signups, logins, feedback, contact, checkout. But too often, they are designed only for visual users. For users relying on screen readers, keyboard navigation, or other assistive technologies, poorly built forms can be a major barrier.

In this step-by-step tutorial, we’ll build a fully accessible HTML form using custom JavaScript validation and WCAG-aligned techniques.

This video demonstrates how the accessible form behaves in the browser: showing validation only after the first submit attempt, highlighting invalid fields, displaying inline and summary errors, and revealing a confirmation screen after successful validation.

We’ll explain every decision along the way and offer clean, reusable code examples for your own projects. But first, why should we do custom validation?

By the end of this article, we should be able to build a form like the one you find in this codepen.

Why custom validation instead of the browser’s native one?

HTML5 gives us native validation with required, type=“email”, and similar attributes. While useful, this approach has important accessibility and UX limitations:

Benefits of custom form validation:

  • Custom, translatable messages: Native error messages are browser-controlled and not always localizable.

  • Full screen reader support: With ARIA attributes, you can link inputs to their error messages semantically.

  • Cross-browser consistency: Native validation styles and behaviors differ across browsers.

  • Control over timing: You decide when and how validation occurs — on blur, after submit, etc.

  • Advanced flows: You can show confirmation screens, store form data temporarily, or do conditional validation.

Validation approaches

There are several approaches to client-side form validation: validating on every keystroke (oninput), on value change (onchange), on focus loss (onblur), or only on form submission. In this tutorial, we’ve chosen a “submit-then-blur” strategy — also known as deferred or progressive validation. This means the form waits to show errors until the first submit attempt, and only then starts validating individual fields as the user revisits them. This approach strikes a balance between usability and accessibility: it avoids overwhelming users with premature feedback, while still offering guidance as they correct mistakes — making it a solid, user-friendly pattern for accessible forms.

Steps for building this accessible form

Step 1: Basic form structure

We start with a simple contact form with the following fields:

  • First name (required)

  • Last name (required)

  • Optional message

  • Checkbox to accept data treatment (required)

Here’s the HTML:

<form id="userForm" novalidate>
  <!-- First Name Input -->
  <div class="form-group">
    <label for="firstName">First Name</label>
    <input
      type="text"
      id="firstName"
      name="firstName"
      autocomplete="given-name"
      aria-required="true"
      aria-invalid="false"
      aria-errormessage="error-firstName"
    />
    <span class="error-message" id="error-firstName" role="alert" hidden></span>
  </div>

  <!-- Last Name Input -->
  <div class="form-group">
    <label for="lastName">Last Name</label>
    <input
      type="text"
      id="lastName"
      name="lastName"
      autocomplete="family-name"
      aria-required="true"
      aria-invalid="false"
      aria-errormessage="error-lastName"
    />
    <span class="error-message" id="error-lastName" role="alert" hidden></span>
  </div>

  <!-- Message Textarea -->
  <div class="form-group">
    <label for="message">Message (optional)</label>
    <textarea id="message" name="message"></textarea>
  </div>

  <!-- Accept Data Checkbox -->
  <div class="form-group">
    <label>
      <input
        type="checkbox"
        id="accept"
        name="accept"
        aria-required="true"
        aria-invalid="false"
        aria-errormessage="error-accept"
      />
      I accept the data processing
    </label>
    <span class="error-message" id="error-accept" role="alert" hidden></span>
  </div>

  <!-- Error Summary -->
  <div id="summary" class="error-summary" aria-live="polite"></div>

  <button type="submit">Submit</button>
</form>

Let’s break down the key parts:

  • novalidate: This attribute disables the browser’s native validation, allowing us to handle it ourselves.
  • aria-required: Indicates whether a field is required. This is important for screen readers.
  • aria-invalid: Indicates whether the field has an error. This is updated dynamically based on validation.
  • aria-errormessage: Links the input to its error message, allowing screen readers to announce it when the input is focused.
  • role="alert": This role is used for error messages to ensure they are announced immediately by screen readers.
  • aria-live="polite": This attribute is used for the error summary to ensure that screen readers announce it when it changes.
  • Error messages: Each input has an associated error message that is hidden by default. We will show it when validation fails.
  • Error summary: This is a container for the error messages. It will be updated dynamically based on the validation results.
  • autocomplete: This attribute is used to help users fill in their information faster. It provides hints to the browser about what type of data is expected in the field.

I assume that you are familiar with the basics of HTML and CSS, so I won’t go into detail about styling. However, you can always take a look at how I did it in the codepen.

That’s why let’s move on to the validation logic using JavaScript.

Step 2: JavaScript validation logic

The process when validating the fields will be the following:

  1. We set the aria-invalid attribute to true if the field is invalid, and false if it is valid.
  2. We set the aria-errormessage attribute to the ID of the error message if the field is invalid, and remove it if it is valid.
  3. We update a summary list below the form so users can see all errors together.
  4. We use role="alert" to ensure that the error message is announced by screen readers.

We’ll start by setting up the form references and the list of fields to validate:

const form = document.getElementById('userForm')
const summary = document.getElementById('summary')

const fields = {
  firstName: {
    required: true,
    element: document.getElementById('firstName'),
    error: document.getElementById('error-firstName'),
    message: 'You must provide a first name.',
  },
  lastName: {
    required: true,
    element: document.getElementById('lastName'),
    error: document.getElementById('error-lastName'),
    message: 'You must provide a last name.',
  },
  accept: {
    required: true,
    element: document.getElementById('accept'),
    error: document.getElementById('error-accept'),
    message: 'You must accept the data processing.',
  },
}

Now let’s create the validateField function that checks if a single field is valid:

function validateField(key) {
  const { required, element, error, message } = fields[key]
  const isValid = required
    ? element.type === 'checkbox'
      ? element.checked
      : element.value.trim() !== ''
    : true

  element.setAttribute('aria-invalid', !isValid)
  error.hidden = isValid
  error.textContent = isValid ? '' : message
  element.classList.toggle('invalid', !isValid)

  return isValid
}

Then we define a helper to collect and show all current errors in a summary:

function getCurrentErrorMessages() {
  return Object.keys(fields)
    .filter((key) => {
      const { required, element } = fields[key]
      return (
        required &&
        (element.type === 'checkbox'
          ? !element.checked
          : element.value.trim() === '')
      )
    })
    .map((key) => fields[key].message)
}

function updateSummary() {
  const messages = getCurrentErrorMessages()
  if (messages.length > 0) {
    summary.innerHTML = `<p>Correct the following errors:</p><ul>${messages.map((msg) => `<li>${msg}</li>`).join('')}</ul>`
  } else {
    summary.innerHTML = ''
  }
}

Now we can build the main function to validate the form:

function validateForm() {
  let allValid = true
  let firstInvalid = null

  Object.keys(fields).forEach((key) => {
    const isValid = validateField(key)
    if (!isValid && !firstInvalid) {
      firstInvalid = fields[key].element
      allValid = false
    }
  })

  updateSummary()

  if (!allValid && firstInvalid) {
    firstInvalid.focus()
  }

  return allValid
}

This function iterates through all fields, validates them, and updates the summary. If any field is invalid, it focuses on the first one.

We’ll now continue to the submit-first-blur-then validation in the next step.

Step 3: Validate only after first submit attempt

Instead of overwhelming users with errors immediately, we wait until the first form submission. After that, each field is validated on blur. This improves usability and reduces frustration for everyone.

let hasAttemptedSubmit = false

form.addEventListener('submit', (e) => {
  e.preventDefault()
  hasAttemptedSubmit = true

  if (validateForm()) {
    showConfirmation()
  }
})

On blur, we revalidate and update the summary accordingly:

field.addEventListener('blur', () => {
  if (hasAttemptedSubmit) {
    validateField(fieldKey)
    updateSummary()
  }
})

Step 4: Focus the first invalid field after submit

Good UX: when the user submits and there are errors, move focus to the first invalid field. This helps both sighted keyboard users and screen reader users find the problem instantly.

if (!isValid && firstInvalidField === null) {
  firstInvalidField = element
}

if (firstInvalidField) {
  firstInvalidField.focus()
}

This addresses WCAG 2.4.3 - Focus Order and 3.3.2 - Labels or Instructions.

Step 5: Don’t rely on color alone

To meet WCAG 1.4.1 - Use of Color, errors are:

Marked with visible text

  • Accompanied by a red border and warning icon (not just color alone)

  • Read aloud by screen readers via role=“alert”

  • This ensures users with low vision or color blindness are still aware of problems.

Step 6: Allow data review and confirmation

After all fields are valid, we show a confirmation screen where users can double-check their input or edit it.

To avoid losing data if the page is accidentally refreshed, we temporarily store form input in sessionStorage and clean it up after final submission.

sessionStorage.setItem('formData', JSON.stringify(data));
...
sessionStorage.removeItem('formData');

WCAG Guidelines Covered

This tutorial tries to cover many WCAG 2.2 guidelines, including:

  • 1.3.1 - Info and Relationships (A): All inputs use semantically correct HTML with associated label elements. This ensures screen readers understand the relationship between fields and labels.
  • 1.4.1 - Use of Color (A): Error states are conveyed with both icons and visible text, not relying on color alone, making the form usable for colorblind users.
  • 1.4.3 - Contrast Minimum (AA): Text, including error messages and button labels, has sufficient contrast with the background to ensure readability.
  • 1.4.11 - Non-Text Contrast (AA): Visual indicators such as input borders and error icons have strong contrast with adjacent elements, aiding users with visual impairments.
  • 2.1.1 - Keyboard Accessibility (A): All elements (inputs, buttons, navigation) are operable via keyboard without needing a mouse or touch input.
  • 2.4.3 - Focus Order (A): The form maintains a logical focus order. When validation fails, focus is moved to the first invalid field, guiding users directly to the problem.
  • 3.3.1 - Error Identification (A): Invalid fields are identified using aria-invalid, and associated messages are clearly rendered and announced via role=“alert”.
  • 3.3.2 - Labels or Instructions (A): Each field has a descriptive label, and required fields are indicated clearly with additional instructions.
  • 3.3.3 - Error Suggestion (AA): Error messages provide helpful suggestions in plain language so users know what to do to fix the problem.
  • 3.3.4 - Error Prevention: Legal, Financial, Data (AA): Before final submission, a confirmation screen allows users to review and edit their data, reducing the chance of mistakes.

Conclusion

Accessible forms are not just a checkbox for compliance — they’re about real people having a fair, smooth, and inclusive experience on the web.

This tutorial gave you a complete, WCAG-aligned approach to accessible forms using just HTML, CSS, and JavaScript. You now have a pattern you can reuse in production applications, and improve upon with your own design system or framework.

Accessibility is not a feature — it’s a responsibility.

Questions about Accessible Forms

Because we use novalidate, native HTML validation is bypassed. aria-required informs assistive tech that a field is still mandatory.


Share article