Building Accessible Forms with Angular

  1. Web Accessibility (A11y) in Angular – Introduction
  2. Accessibility Testing Tools for Angular
  3. Accessible Angular Routes
  4. ARIA roles and attributes in Angular
  5. Building Accessible Forms with Angular

Accessible Angular Forms are essential to ensure that all our users – including those with disabilities – can interact with our Angular App effectively. By implementing forms with Accessibility (A11y) in mind, we meet legal (EAA 2025 ♿) and ethical standards, and create a more inclusive experience. Accessible forms work better with screen readers, keyboard navigation, and assistive technologies – which not only helps users with impairments but also enhances the overall UX of our Angular Apps.

Angular Forms

In Angular, we have two types of forms:

  • Template-driven Forms: These are simpler and more declarative, relying on Angular Directives to create forms. They are built around the NgModel directive and generally used for simple forms. As the name suggests, these are implemented in the template through attributes.
  • Reactive Forms: These are more powerful and flexible, allowing for complex form structures and dynamic validation. They are built in the component class around the FormGroup and FormControl classes. Most often the FormBuilder service is used to create the forms.

In 2025, the Angular team is expected to work on Signal integration for Angular Forms.

However, no matter which type of form you choose – and whether you migrate from Observables to Signals or not – the goal is to ensure that your forms are accessible to all users. This includes providing proper labels, error messages, and keyboard navigation. To keep the focus on A11y, we will use the simpler template-driven approach without any reactivity in our examples.

Keyboard Navigation & Tab Focus

Keyboard navigation is a critical part of form A11y. Many users rely on the keyboard – rather than a mouse – to move through a form using the Tab, Shift + Tab, Arrow, and Enter keys. Ensuring a logical tab order, using semantic HTML elements like <label>, <input>, <button>, and avoiding custom controls that break default behavior, helps users navigate efficiently. You should not change the order. However, you can add tabindex="0" to include non-interactive elements or custom components, and use tabindex="-1" to remove elements from the tab order.

Additionally, visual focus indicators (like outlines, at least 2 if not 3px width) should be clearly visible to show which element is currently active. When done right, keyboard-friendly forms not only improve A11y but also lead to a smoother and more intuitive experience for all users 😎

Form Fields

Labels & type attribute

To ensure A11y, always associate <label> elements with their corresponding form controls using the for and id attributes. This improves support for screen readers and enables better keyboard navigation – clicking a label should focus the related input. Make sure each id is unique, especially when using multiple forms on the same page. Additionally, specify the correct type for all <input> and <button> elements to ensure proper behavior, like submitting a form when pressing Enter on a <button type="submit">.

<label for="from">From</label> <input type="text" name="from" id="from" [...] />

Grouping fields

When we have a group of related inputs, especially radio buttons or checkboxes, screen readers benefit from extra semantic context by <fieldset> and <legend>:

<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>

This provides context for assistive tech. Without this, users may hear "Economy" and "Business" without understanding they are part of a group. The <fieldset> element groups related controls, while the <legend> provides a caption for the group.

Required fields

When a form element (like <input>, <select>, or <textarea>) must have a value, use the required attribute. This prevents form submission unless the required fields are filled out, and helps users with assistive technologies understand which fields need valid content. For example, add the required attribute to both input fields in your form, and consider adding an asterisk (*) as an additional visual indicator.

<label for="name">Name (*)</label> <input type="text" name="name" id="name" required />

Autocomplete

The autocomplete attribute is a powerful tool for improving the user experience in forms. It allows browsers to remember and suggest previously entered values, making it easier for users to fill out forms quickly. By using the autocomplete attribute, you can specify the type of data expected in each field, such as name, email, or address. This not only enhances usability but also helps with A11y by providing clear context for screen readers and assistive technologies.

<input type="text" name="phone" autocomplete="phone" />

To avoid autocomplete, we should set the autocomplete attribute to off. This is especially useful for sensitive information like passwords or when you want to ensure that users enter fresh data.

<input type="password" name="password" autocomplete="off" />

ARIA attributes

ARIA attributes (more on them in our last post) can enhance the A11y of our Angular Forms by providing additional context to assistive technologies when native HTML alone isn’t enough. Attributes like aria-label or aria-labelledby can offer accessible names for form controls when visible labels aren’t practical. While ARIA should never replace semantic HTML, it’s a powerful tool to bridge A11y gaps and ensure all users can understand and interact with your forms effectively.

<input type="search" name="search" aria-label="Search flights" placeholder="Search..." />

For validation, aria-invalid="true" can indicate a validation error and aria-describedby can provide additional context or instructions. For example, if a user enters an invalid email address, you can set aria-invalid="true" on the input field and use aria-describedby to point to an error message that explains the issue.

@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" />

Speaking about error messages, let's take a look at how to handle them.

Error messages

Accessible error messages help all users understand and correct form issues. Use aria-describedby (as in the example above) to link inputs to their error messages, and add aria-live="polite" to ensure screen readers announce them when they appear. Messages should be clear, concise, and not rely on color alone – always provide text or icons for better clarity.

Personal preferences of error messages:

  • only after user interaction with the form: on blur ("touched" in Angular) or after submitting.
  • focus on the first invalid control (see code example below) upon submitting.
  • inline with the form fields, not at the top or bottom of the form.
  • after the form field, not before.
  • in (dark) red and with an icon (e.g., ❌) to make them more visible.
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>

Accessibility Workshop

For those looking to deepen their Angular expertise, we offer a range of workshops – both in English and German:

Conclusion

Building accessible forms in Angular is not just a best practice – it’s a commitment to creating better experiences for everyone. With just a few thoughtful choices, you can make your forms inclusive, intuitive, and ready for the future. For more information on Angular and Accessibility, check out my A11y blog series.

This blog post was written by Alexander Thalhammer. Follow me on Linkedin, X or giThub.