Accessible Forms System — Building Inclusive Form Components
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
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