Skip to content

Accessible Forms System — Building Inclusive Form Components

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about Accessible Forms System. We cover key concepts, practical examples, and best practices to help you master this topic.

An accessible forms system provides form components with programmatically associated labels, clear error messaging with recovery suggestions, required field indicators, logical tab order, and ARIA validation attributes.

What You'll Learn

You will learn how to build accessible form components for a design system, how to handle validation and errors accessibly, and how to ensure form fields work with assistive technologies.

Why It Matters

Forms are one of the most critical interactions on the web. Inaccessible forms prevent users from completing purchases, signing up, submitting data, and performing essential tasks.

Real-World Use

DodaKit's form system includes accessible text inputs, selects, checkboxes, radio buttons, and textareas. Every component has built-in label association, error messaging, and validation attributes.

flowchart TD
  A[Form System] --> B[Input Fields]
  A --> C[Labels]
  A --> D[Error States]
  A --> E[Validation]
  A --> F[Layout]
  B --> B1[Text, select, checkbox, radio]
  C --> C1[Programmatic association]
  D --> D1[aria-describedby]
  E --> E1[aria-invalid]
  F --> F1[Single column preferred]

Label Association

Every form control must have a programmatically associated label. Use the label element with a for attribute matching the input id, or use aria-label if a visible label is not possible.

Error Messaging

Error messages must be programmatically associated with the input using aria-describedby. The error should be announced by screen readers when it appears.

Validation

Use aria-invalid="true" on inputs with validation errors. Update it to aria-invalid="false" when corrected. Use appropriate input types (email, url, tel) for built-in validation.

// Accessible form field component
class AccessibleFormField {
  constructor(config) {
    this.id = config.id;
    this.label = config.label;
    this.type = config.type || 'text';
    this.required = config.required || false;
    this.errorMessage = '';
    this.value = '';
    this.touched = false;
  }

  setValue(value) {
    this.value = value;
    this.touched = true;
    this.validate();
  }

  validate() {
    if (this.required && !this.value.trim()) {
      this.errorMessage = `${this.label} is required.`;
      return false;
    }

    if (this.type === 'email' && this.value && !this.value.includes('@')) {
      this.errorMessage = 'Enter a valid email address.';
      return false;
    }

    this.errorMessage = '';
    return true;
  }

  render() {
    const errorId = `${this.id}-error`;
    const hintId = `${this.id}-hint`;
    const hasError = this.touched && this.errorMessage;

    return `<div class="ds-form-field ${hasError ? 'ds-form-field--error' : ''}">
      <label for="${this.id}" class="ds-form-field__label">
        ${this.label}
        ${this.required ? '<span class="ds-form-field__required" aria-hidden="true"> *</span>' : ''}
      </label>

      <input
        id="${this.id}"
        class="ds-form-field__input"
        type="${this.type}"
        ${this.required ? 'required' : ''}
        ${hasError ? `aria-invalid="true" aria-describedby="${errorId}"` : `aria-describedby="${hintId}"`}
        value="${this.value}">

      ${hasError
        ? `<p id="${errorId}" class="ds-form-field__error" role="alert">${this.errorMessage}</p>`
        : `<p id="${hintId}" class="ds-form-field__hint">${this.label} is required.</p>`
      }
    </div>`;
  }
}

const emailField = new AccessibleFormField({
  id: 'user-email',
  label: 'Email address',
  type: 'email',
  required: true
});

console.log(emailField.render());
emailField.setValue('invalid');
console.log(emailField.render());

Expected output:

<div class="ds-form-field ">
  <label for="user-email" class="ds-form-field__label">Email address<span class="ds-form-field__required" aria-hidden="true"> *</span></label>
  <input id="user-email" class="ds-form-field__input" type="email" required aria-describedby="user-email-hint" value="">
  <p id="user-email-hint" class="ds-form-field__hint">Email address is required.</p>
</div>
<div class="ds-form-field ds-form-field--error">
  <label for="user-email" class="ds-form-field__label">Email address<span class="ds-form-field__required" aria-hidden="true"> *</span></label>
  <input id="user-email" class="ds-form-field__input" type="email" required aria-invalid="true" aria-describedby="user-email-error" value="invalid">
  <p id="user-email-error" class="ds-form-field__error" role="alert">Enter a valid email address.</p>
</div>

Form Layout

Single column layouts are most accessible. Label above input for most fields. Inline layouts for checkboxes and radio groups.

<!-- Accessible form group -->
<form class="ds-form" novalidate aria-label="User registration">
  <fieldset class="ds-form__fieldset">
    <legend class="ds-form__legend">Account information</legend>

    <div class="ds-form-field">
      <label for="full-name" class="ds-form-field__label">Full name <span aria-hidden="true">*</span></label>
      <input id="full-name" class="ds-form-field__input" type="text" required aria-required="true">
    </div>

    <div class="ds-form-field">
      <label for="email" class="ds-form-field__label">Email address <span aria-hidden="true">*</span></label>
      <input id="email" class="ds-form-field__input" type="email" required aria-required="true">
    </div>
  </fieldset>

  <fieldset class="ds-form__fieldset">
    <legend class="ds-form__legend">Preferences</legend>

    <div class="ds-form-field ds-form-field--checkbox">
      <input id="notifications" type="checkbox" class="ds-form-field__checkbox">
      <label for="notifications" class="ds-form-field__label">Send me security alerts</label>
    </div>
  </fieldset>

  <div class="ds-form__actions">
    <button type="submit" class="ds-button ds-button--primary">Create account</button>
    <button type="reset" class="ds-button ds-button--secondary">Clear</button>
  </div>
</form>

Common Mistakes

1. Placeholder Text as Label

Placeholder disappears when user types. It is not a label. Always use a label element.

2. No Error Association

Error messages displayed near the input but not programmatically associated are not announced by screen readers.

3. Removing Required Validation Messages

When using novalidate, implement custom validation that meets the same standards as native validation.

4. Inline Labels That Wrap

Label beside input works at wide widths but breaks on mobile. Single column with label above is safer.

5. No Visible Focus on Form Controls

Form controls must have visible focus indicators. Ensure the focus ring is visible on all form fields.

6. Fieldsets Without Legends

Groups of related fields need fieldset and legend for screen reader context.

7. Validation Only on Submit

Validate on blur for early feedback. Use aria-describedby to associate live error messages.

Practice Questions

1. How should labels be programmatically associated with inputs?

Using the label element with a for attribute matching the input id, or using aria-label.

2. What ARIA attribute associates an error message with an input?

aria-describedby pointing to the error message element's id.

3. What ARIA attribute indicates an input has a validation error?

aria-invalid="true".

4. Why should forms use a single column layout?

Single column is more accessible for users with cognitive disabilities and works better at zoom and on mobile.

5. Challenge: Create an accessible form fieldset with 4 fields. Include label association, required markers, error messages, and validation.

FAQ

Can I use placeholder as a label?

No. Placeholder text disappears when the user types and has poor contrast. Always use a visible label element.

How do I mark a field as required?

Use the required attribute on the input and indicate visually with an asterisk and aria-hidden='true' or 'required' text.

Should I use native or custom validation?

Native validation is simpler and more reliable. Use custom validation only when you need behavior beyond native support.

How do I handle password fields accessibly?

Use type='password' with a show/hide toggle button. Associate the toggle with aria-pressed and aria-label.

What is the best layout for radio buttons?

Stack radio buttons vertically. Avoid horizontal layouts that make it hard to associate labels with radios.

Mini Project

Create an accessible form component library with 5 field types (text, email, select, checkbox, radio). Each must include label association, error state, and validation. Document the keyboard interactions.

What's Next

Learn about Accessible Navigation System patterns in design systems. Then explore Documentation for a11y.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro