<label for="from">From</label> <input type="text" name="from" id="from" [...] />
<fieldset>
<legend>Flight Class</legend>
<label for="economy">Economy</label>
<input type="radio" name="class" id="economy" value="economy" />
<label for="business">Business</label>
<input type="radio" name="class" id="business" value="business" />
</fieldset>
<label for="name">Name (*)</label> <input type="text" name="name" id="name" required />
<input type="text" name="phone" autocomplete="phone" />
<input type="password" name="password" autocomplete="off" />
<input type="search" name="search" aria-label="Search flights" placeholder="Search..." />
@let hasFromErrors = flightSearchForm.controls['from'] && flightSearchForm.controls['from'].touched && flightSearchForm.controls['from'].errors;
<input type="text" name="from" id="from" required [attr.aria-invalid]="!!hasFromErrors" [attr.aria-describedby]="hasFromErrors ? 'from_error' : null" />
export class FlightSearchComponent {
private readonly document = inject(DOCUMENT); // for the focus
private readonly flightSearchForm = viewChild.required<NgForm>('flightSearchForm');
protected onSearch(): void {
if (this.flightSearchForm()?.invalid) {
this.markFormGroupTouched(this.flightSearchForm());
this.focusFirstInvalidControl(this.flightSearchForm());
return;
}
// do the search
}
private markFormGroupTouched(formGroup: FormGroup): void {
for (const key of Object.keys(formGroup.controls)) {
const control = formGroup.get(key);
if (control instanceof FormGroup) {
this.markFormGroupTouched(control);
} else {
control?.markAsTouched();
}
});
}
private focusFirstInvalidControl(formGroup: FormGroup): void {
for (const key of Object.keys(formGroup.controls)) {
const control = formGroup.get(key);
if (control?.invalid) {
const invalidControl = this.document.querySelector(`[name="${key}"]`);
(invalidControl as HTMLElement)?.focus();
break;
}
}
}
}
<form #flightSearchForm="ngForm">
<label for="fromAirport">From (*)</label>
@let hasFromErrors = flightSearchForm.controls['from'] && flightSearchForm.controls['from'].touched && flightSearchForm.controls['from'].errors;
<input
type="text"
name="from"
id="fromAirport"
required
[minlength]="minLength"
[maxlength]="maxLength"
[pattern]="pattern"
[attr.aria-invalid]="!!hasFromErrors"
[attr.aria-describedby]="hasFromErrors ? 'fromErrors' : null"
[(ngModel)]="from"
/>
@if (hasFromErrors) {
<app-flight-validation-errors id="fromErrors" [errors]="flightSearchForm.controls['from'].errors" fieldLabel="From" />
}
</form>
The Two Flavors of Angular Forms
Angular offers two distinct approaches to building forms, each with its own strengths:
- Template-driven Forms: These lean on Angular Directives and the
NgModeldirective for a declarative setup. They’re ideal for straightforward scenarios and are defined directly in the template via attributes. - Reactive Forms: These provide greater flexibility and control, built around
FormGroupandFormControlin the component class. TheFormBuilderservice is frequently used to streamline construction.
Looking ahead, the Angular team has plans to integrate Signals into the forms module in 2025.
Regardless of which approach you pick—and whether you switch from Observables to Signals—the core objective remains: forms must be accessible to everyone. This means proper labels, clear error messaging, and seamless keyboard interaction. For our examples, we’ll stick with the simpler template-driven forms to keep the focus squarely on accessibility.
Keyboard Navigation and Focus Management
Keyboard navigation is a cornerstone of form accessibility. Many users navigate with Tab, Shift + Tab, Arrow, and Enter keys instead of a mouse. Maintaining a logical tab order, relying on semantic elements like <label>, <input>, and <button>, and avoiding custom widgets that disrupt native behavior are all key. Don’t alter the default order. However, tabindex="0" can bring non-interactive or custom elements into the tab sequence, while tabindex="-1" can remove them from it.
Visible focus indicators—such as outlines of at least 2–3px—are also vital for showing users where they are. When keyboard support is handled properly, it not only boosts accessibility but also creates a more fluid, intuitive experience for everyone. 😎
Designing Accessible Form Fields
Labels and Input Types
Always pair every form control with a <label> using the for and id attributes. This pairing is critical for screen readers and makes clicking a label focus its input. Ensure each id is unique, especially when multiple forms share a page. Also, set the correct type on <input> and <button> elements—for instance, <button type="submit"> should trigger submission on Enter.
Structuring Related Controls
For clusters of related inputs—particularly radio buttons or checkboxes—screen readers benefit from the semantic grouping provided by <fieldset> and <legend>:
This structure gives assistive technology essential context. Without it, users might hear "Economy" and "Business" without realizing they belong to the same group. The <fieldset> wraps the related controls, while the <legend> supplies a descriptive caption for the group.
Marking Required Fields
Use the required attribute on any form element—like <input>, <select>, or <textarea>—that demands a value. This blocks submission until all required fields are completed and signals to assistive tools which fields need valid input. Add required to your inputs and consider appending an asterisk (*) as a visual cue.
Leveraging Autocomplete
The autocomplete attribute can significantly speed up form filling by letting browsers recall and suggest previously entered data. Specifying expected data types—such as name, email, or address—not only improves usability but also gives screen readers clearer context. Setting autocomplete to off is a safeguard for sensitive fields like passwords or any input that must be fresh.
Enhancing with ARIA
ARIA attributes—detailed in our earlier post—can fill gaps where native HTML falls short. For example, aria-label or aria-labelledby can provide accessible names when a visible label isn’t feasible. ARIA should never substitute for semantic HTML, but it’s an invaluable tool for closing accessibility gaps.
For validation feedback, aria-invalid="true" flags an error, while aria-describedby links to additional instructions or error text. If a user submits an invalid email, you could set aria-invalid="true" on that field and use aria-describedby to reference a message explaining the problem.
That leads us directly to handling error messages.
Delivering Accessible Error Messages
Effective error messages help users identify and rectify issues. Connect inputs to their messages using aria-describedby, and apply aria-live="polite" so screen readers announce new errors automatically. Keep messages concise and clear, avoiding color as the sole differentiator—consider adding text or icons for clarity.
My own preferences for error display:
- Show errors only after user interaction—either on blur (when a field is "touched" in Angular) or post-submission.
- Upon submission, move focus to the first invalid control.
- Place messages inline next to the offending fields, not in a separate summary.
- Position them after the field, never before.
- Use dark red text with an icon (like ❌) for maximum visibility.
Further Learning Opportunities
To expand your Angular skills further, we provide workshops in both English and German:
- ♿ Accessibility Workshop
- 📈 Best Practices Workshop (which includes accessibility topics)
- 🚀 Performance Workshop
Wrapping Up
Crafting accessible forms in Angular is more than a technical requirement—it’s a commitment to building inclusive digital experiences. By making deliberate, thoughtful choices, your forms can be both user-friendly and future-proof. For deeper insights into Angular and Accessibility, explore my full series.
