Skip to content

Accessible Forms — Complete Guide

DodaTech Updated 2026-06-28 8 min read

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

Accessible forms use proper labels, clear error handling, logical field grouping, input purpose auto-complete attributes, and a logical focus order to ensure all users can understand and complete them successfully.

What You'll Learn

  • How to correctly associate labels with form controls
  • How to group related fields with fieldset and legend
  • Accessible error validation and messaging
  • Input purpose attributes for auto-fill
  • Keyboard navigation through forms
  • Custom form controls Accessibility

Why It Matters

  • Forms are the primary way users interact with websites
  • Inaccessible forms prevent users from registering, purchasing, or contacting
  • WCAG requires labels, error identification, and input assistance
  • Well-designed forms have higher completion rates for all users

Real-World Use

  • A checkout form groups billing and shipping sections
  • A registration form validates input and announces errors clearly
  • A search form has a visible label and autocomplete support
  • A contact form uses appropriate input types for phone, email, and date
flowchart LR
  A[Form Structure] --> B[Labels]
  A --> C[Input Types]
  A --> D[Validation]
  A --> E[Submission]
  B --> F[for attribute]
  C --> G[type, autocomplete]
  D --> H[Error Messages]
  E --> I[Confirmation]

Accessible Form Fundamentals

An accessible form starts with labels. Every form control must have a label that is programmatically associated with it. The most reliable way is using the <label> element with the for attribute.

Label Association

There are several ways to associate a label with a form control:

  1. Explicit label (best): <label for="email">Email</label><input id="email">
  2. Implicit label: <label>Email <input></label>
  3. aria-label: <input aria-label="Email">
  4. aria-labelledby: <input aria-labelledby="email-label">

The explicit label with for and id is the most reliable across browsers and assistive technologies.

Input Purpose Attributes

HTML5 introduced the autocomplete attribute that tells browsers and assistive technologies the purpose of each field. This enables password managers, auto-fill, and faster form completion.

<!-- Common autocomplete values -->
<input type="text" name="name" autocomplete="name">
<input type="email" name="email" autocomplete="email">
<input type="tel" name="phone" autocomplete="tel">
<input type="text" name="address" autocomplete="street-address">
<input type="text" name="city" autocomplete="address-level2">
<input type="text" name="country" autocomplete="country-name">
<input type="password" name="password" autocomplete="new-password">

Code Example: Complete Accessible Form

<form action="/register" method="POST" novalidate>
    <h1>Create Your Account</h1>
    <p>Required fields are marked with <span aria-hidden="true">*</span></p>

    <!-- Personal information section -->
    <fieldset>
        <legend>Personal Information</legend>

        <div>
            <label for="first-name">
                First Name <span aria-hidden="true">*</span>
            </label>
            <input type="text"
                   id="first-name"
                   name="first_name"
                   autocomplete="given-name"
                   required
                   aria-required="true"
                   aria-describedby="fn-desc fn-error">
            <span id="fn-desc" class="hint">Enter your legal first name.</span>
            <span id="fn-error" class="error" role="alert" hidden></span>
        </div>

        <div>
            <label for="last-name">
                Last Name <span aria-hidden="true">*</span>
            </label>
            <input type="text"
                   id="last-name"
                   name="last_name"
                   autocomplete="family-name"
                   required
                   aria-required="true"
                   aria-describedby="ln-error">
            <span id="ln-error" class="error" role="alert" hidden></span>
        </div>

        <div>
            <label for="email">Email Address <span aria-hidden="true">*</span></label>
            <input type="email"
                   id="email"
                   name="email"
                   autocomplete="email"
                   required
                   aria-required="true"
                   aria-describedby="email-error">
            <span id="email-error" class="error" role="alert" hidden></span>
        </div>
    </fieldset>

    <!-- Account section -->
    <fieldset>
        <legend>Account Details</legend>

        <div>
            <label for="username">Username <span aria-hidden="true">*</span></label>
            <input type="text"
                   id="username"
                   name="username"
                   autocomplete="username"
                   required
                   aria-required="true"
                   minlength="3"
                   aria-describedby="username-hint username-error">
            <span id="username-hint" class="hint">At least 3 characters, letters and numbers only.</span>
            <span id="username-error" class="error" role="alert" hidden></span>
        </div>

        <div>
            <label for="password">Password <span aria-hidden="true">*</span></label>
            <input type="password"
                   id="password"
                   name="password"
                   autocomplete="new-password"
                   required
                   aria-required="true"
                   minlength="8"
                   aria-describedby="pw-hint pw-error">
            <span id="pw-hint" class="hint">At least 8 characters with uppercase, lowercase, and one number.</span>
            <span id="pw-error" class="error" role="alert" hidden></span>
        </div>

        <div>
            <label for="password-confirm">Confirm Password <span aria-hidden="true">*</span></label>
            <input type="password"
                   id="password-confirm"
                   name="password_confirm"
                   autocomplete="new-password"
                   required
                   aria-required="true"
                   aria-describedby="pw-confirm-error">
            <span id="pw-confirm-error" class="error" role="alert" hidden></span>
        </div>
    </fieldset>

    <!-- Preferences section -->
    <fieldset>
        <legend>Preferences</legend>

        <div>
            <label for="newsletter">
                <input type="checkbox" id="newsletter" name="newsletter" checked>
                Subscribe to our newsletter
            </label>
        </div>

        <fieldset>
            <legend>Preferred Contact Method</legend>
            <label>
                <input type="radio" name="contact" value="email" checked>
                Email
            </label>
            <label>
                <input type="radio" name="contact" value="phone">
                Phone
            </label>
        </fieldset>

        <div>
            <label for="country">Country</label>
            <select id="country" name="country">
                <option value="">Select a country</option>
                <option value="US">United States</option>
                <option value="CA">Canada</option>
                <option value="UK">United Kingdom</option>
            </select>
        </div>
    </fieldset>

    <button type="submit">Create Account</button>
</form>

<style>
.error { color: #C62828; font-size: 0.85rem; display: block; }
.hint { color: #666; font-size: 0.85rem; display: block; }
[aria-invalid="true"] { border-color: #C62828; }
</style>

Expected output: Screen readers navigate through fieldsets and announce each legend. Labels are announced for each input. Hints provide additional context. Errors are announced when validation fails. The autocomplete attributes enable browser auto-fill.

Code Example: Accessible Validation

// Accessible form validation
const form = document.querySelector('form');
form.addEventListener('submit', function(event) {
    event.preventDefault();
    let hasError = false;

    // Clear previous errors
    document.querySelectorAll('.error').forEach(el => {
        el.hidden = true;
        el.textContent = '';
    });
    document.querySelectorAll('[aria-invalid]').forEach(el => {
        el.removeAttribute('aria-invalid');
    });

    // Validate each field
    const fields = [
        { id: 'first-name', test: val => val.trim().length > 0, msg: 'First name is required.' },
        { id: 'last-name', test: val => val.trim().length > 0, msg: 'Last name is required.' },
        { id: 'email', test: val => val.includes('@') && val.includes('.'), msg: 'Enter a valid email address.' },
        { id: 'username', test: val => val.trim().length >= 3, msg: 'Username must be at least 3 characters.' },
        { id: 'password', test: val => val.length >= 8 && /[A-Z]/.test(val) && /[a-z]/.test(val) && /[0-9]/.test(val), msg: 'Password does not meet requirements.' },
        { id: 'password-confirm', test: (val, form) => val === form.querySelector('#password').value, msg: 'Passwords do not match.' }
    ];

    fields.forEach(field => {
        const input = document.getElementById(field.id);
        const error = document.getElementById(field.id + '-error');
        const value = input.value;

        // Pass the form as second argument for cross-field validation
        if (!field.test(value, form)) {
            input.setAttribute('aria-invalid', 'true');
            error.textContent = field.msg;
            error.hidden = false;
            if (!hasError) {
                input.focus();
                hasError = true;
            }
        }
    });

    if (!hasError) {
        const confirmation = document.createElement('div');
        confirmation.setAttribute('role', 'alert');
        confirmation.textContent = 'Account created successfully!';
        form.prepend(confirmation);
    }
});

Expected output: When submitted with errors, the first invalid field receives focus. Each error appears next to its field and is announced by screen readers via the role="alert" attribute. Valid fields are cleared of previous errors.

Code Example: Custom Checkbox and Radio

<!-- Accessible custom checkbox -->
<style>
    .custom-checkbox {
        display: inline-flex;
        align-items: center;
        gap: 0.5rem;
        cursor: pointer;
    }
    .custom-checkbox input[type="checkbox"] {
        position: absolute;
        opacity: 0;
        width: 1px;
        height: 1px;
    }
    .custom-checkbox .checkmark {
        width: 20px;
        height: 20px;
        border: 2px solid #666;
        border-radius: 3px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
    }
    .custom-checkbox input:focus-visible + .checkmark {
        outline: 3px solid #0056B3;
        outline-offset: 2px;
    }
    .custom-checkbox input:checked + .checkmark::after {
        content: "✓";
        color: white;
        font-weight: bold;
    }
    .custom-checkbox input:checked + .checkmark {
        background: #0056B3;
        border-color: #0056B3;
    }
</style>

<label class="custom-checkbox">
    <input type="checkbox" id="terms" required>
    <span class="checkmark" aria-hidden="true"></span>
    <span>I agree to the <a href="/terms">Terms and Conditions</a></span>
</label>

Expected output: The native checkbox is visually hidden but still focusable and accessible. Screen readers announce it as a checkbox. The visual custom styling replaces the default appearance. The focus indicator is visible for keyboard users.

Common Mistakes

  1. Missing or incorrect labels — Placeholder text is not a label. It disappears when the user types and fails WCAG. Always use a proper label element.
  2. Error messages without association — An error message that is not associated with its input (via aria-describedby) is not announced by screen readers.
  3. Grouping fields without fieldset — Related fields like shipping address or payment method should be wrapped in a fieldset with a legend.
  4. Relying on color alone for errors — Red text for errors is invisible to colorblind users. Always include an icon or text indicator like "Error:".
  5. Autocomplete attributes missing — Without autocomplete attributes, password managers and auto-fill cannot populate fields, making forms slower to complete.
  6. Submit button disabled without explanation — A disabled button that does not explain why prevents users from understanding what they need to fix.
  7. No confirmation after submission — Users need to know the form was received successfully. Provide a clear confirmation message with role="alert".

Practice Questions

  1. What is the most reliable way to associate a label with a form control? Using the <label> element with the for attribute matching the input's id.
  2. What is the purpose of the autocomplete attribute? It tells browsers and assistive technologies the purpose of each input field, enabling auto-fill, password managers, and faster form completion.
  3. Why should you use <fieldset> and <legend> for related form controls? They group related fields together, and screen readers announce the legend when navigating between fields in the group.
  4. How do you mark a field as required accessibly? Use the required attribute (which adds implicit aria-required) and visibly mark the field with an asterisk or "required" text.
  5. Challenge: Build a multi-step checkout form (Shipping, Payment, Review) with full accessibility. Include shipping address fields grouped in a fieldset, payment method with radio buttons, order summary with a data table, and form validation that uses aria-describedby for errors. Test with keyboard only and a screen reader.

FAQ

Can I use placeholder text as a label?

No. Placeholder text disappears when the user types, fails color contrast minimums, and is not a substitute for a proper label element.

How do I handle CAPTCHA accessibly?

Avoid visual CAPTCHAs entirely. Use honeypot fields, rate limiting, or a checkbox-based challenge (like Cloudflare Turnstile or reCAPTCHA v3) that is accessible.

Should every form input have an autocomplete attribute?

You should add autocomplete attributes to all fields where the browser can provide value: name, email, address, phone, credit card, username, and password fields.

{{< faq "How do I make a date picker accessible?" "Use `` which has native accessibility. If building a custom date picker, use role=\"dialog\", role=\"grid\", and role=\"gridcell\" with appropriate ARIA states." >}}
What is the best way to handle inline validation?

Validate on blur (when the user leaves the field) and provide clear error messages associated via aria-describedby. Never validate while the user is still typing.

Mini Project

Build a complete accessible registration form. Include: 3 fieldsets (Personal Info, Account Details, Preferences), proper labels with for attributes, autocomplete on all appropriate fields, real-time validation with aria-describedby error messages, a custom checkbox for terms agreement (visually custom but natively functional), a custom select dropdown (fully keyboard accessible), and a confirmation screen with role="alert" after successful submission. Style the form clearly with proper focus indicators. Test with keyboard-only navigation and a screen reader.

What's Next

Continue with Lesson 15: Accessible Data Tables to learn how to present tabular data accessibly with proper headers, captions, and scope attributes.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro