Presenting Form Errors with Usability and Accessibility in Mind

At Haven Technologies, we aim to make life better. That’s no easy feat when your business is life insurance, an industry that isn’t exactly known for being easy to navigate.

As a SaaS tech company, our main customer is an insurance agency, and one of the ways we can make life better is by simplifying the experience of applying for life insurance online — and in my opinion, one of the most difficult parts of applying for life insurance is filling out the application form. Historically, insurance applications have been dreadfully long, dense, and difficult to comprehend without a background in actuarial science. Making even a single mistake while filling it out might impact your application. Additionally, people who have certain disabilities may have trouble applying for insurance on their own altogether, due to the unavailability of accessible alternatives to a paper application.

So when we helped our customer redesign the application experience for their flagship direct-to-consumer insurance product, we wanted to empower applicants to complete their own insurance applications with as few mistakes as possible, regardless of their abilities or disabilities. An important piece of that was to make sure that form validation errors were clear, perceivable, and available to all users. This post describes all the considerations that helped us achieve that goal when we were building the Form Field Error component for the Haven Design System (HDS), our internal UI kit and Angular component library for direct-to-consumer applications.

A set of three form fields, two of which are in invalid states

Providing the right context

An effective error message is:

  • Specific — The error message gives the user all the necessary information to correct the error
  • Succinct — The error message gets to the point, so you can fix the error faster
  • Consistent — If two fields have exactly the same error, they use exactly the same error message

The best way to ensure all three of these qualities is to provide reasonable default error messages for the most common types of errors, but to allow the application to override those error messages when it really matters. To accomplish this, HDS defines an error message context — a certain scope within which a set of errors map to a corresponding set of error messages. By thinking of errors in terms of their context, we can define a sensible default context to cover most use cases, then override that context for less common errors.

In Angular, we can do this via dependency injection. HDS offers an abstract ErrorMessageContext class you can inject into any component, and a default implementation of that class for the most common error types:

@Injectable({
providedIn: 'root',
// The default implementation is provided here to ensure
// that there is always a context, even if the form that
// uses the context doesn’t provide its own
useClass: forwardRef(() => DefaultErrorMessageContext),
})
export abstract class ErrorMessageContext {
// The abstract class defines a contract, so the Form Field Error
// knows how to fetch the error message from it
abstract getErrorMessage(errors?: ValidationErrors | null): string | void;
}
@Injectable()
export class DefaultErrorMessageContext implements ErrorMessageContext {
getErrorMessage(errors?: ValidationErrors | null): string | void {
if (!errors) {
return;
}
if (errors.required) {
return 'Required';
}
if (errors.minlength) {
return `Value must be at least ${errors.minlength.requiredLength} characters long`;
}
// etc...
}
}

Our applications can use the default implementation out of the box, or they can override it to handle any custom errors. For example, an application might choose to load custom error messages from a JSON metadata file, and defer to the next-defined context if a custom error isn’t found:

@Component({
// ...
providers: [{ provide: ErrorMessageContext, useExisting: MyComponent }]
})
export class MyComponent implements ErrorMessageContext {
// Inject the parent context, in case this context doesn't handle a specific error
constructor(@SkipSelf() @Inject(ErrorMessageContext) private parentContext: ErrorMessageContext) {}
getErrorMessage(errors?: ValidationErrors | null): string | void {
return this.getOverrideErrorMessage(errors) || this.parentContext.getErrorMessage(errors);
}
private getOverrideErrorMessage(errors?: ValidationErrors | null): string | void {
// Load error message definition from JSON
}
}

The Form Field Error component can then find the context that applies wherever it’s being used to determine the appropriate error message to display:

@Component({
selector: 'hl-form-field-error',
// ...
})
export class FormFieldErrorComponent {
// We pass in the control being validated
@Input() control?: NgControl | AbstractControl;
constructor(
// This will find the nearest context defined on either the component or its ancestors
@Inject(ErrorMessageContext) private context: ErrorMessageContext
) {}
get errorMessage(): string | null {
return this.control?.errors ? this.context?.getErrorMessage(this.control.errors) : null;
}
}

With dependency injection, we’re able to provide the right error in the right place. Now, let’s make sure errors show at the right time.

Showing errors when they’re needed most

There are two critical moments when someone is filling out a form where we can warn them that they may have made a mistake: right after they make the error, and just before they try to submit the form. We handle both of those cases to catch every error before it could count against an applicants’ submission.

We want to catch most errors immediately, so the applications use inline validation for every form field. As soon as the applicant “blurs” the field (either by tabbing out of the field, or by clicking somewhere else on the page), we trigger our validation logic. Angular’s reactive forms make it simple to validate forms on blur:

// my-form.component.tsform = new FormGroup(
{
// Initialize some form controls with validation logic...
myControl: new FormControl(null, [Validators.required]),
},
// …then tell Angular to run validation whenever the user blurs one of the controls
{ updateOn: 'blur' }
);

But we can’t always catch errors with inline validation. For example, applicants who primarily use their mouse to navigate the application form may accidentally skip some fields; since they never interacted with the fields they missed, we don’t know that we need to validate them inline! To make sure we don’t miss any fields in those situations, we also perform a full validation check of the entire form when the applicant tries to submit it.

If we find any errors at this point, it’s not enough just to show the error messages next to the invalid fields — some application forms are long enough that the applicant might not see the error message for a field they missed at the beginning of the form. To correct for this, we also display a form-level alert message with a link to the first invalid field in the form, so they can easily get to their error and correct it.

An error summary appears if some fields are invalid after the applicant tries to submit their application.

Making error messages accessible

By this point, we have error messages that our users can see in the right place, at the right time. But what if you can’t actually see them? For a variety of reasons, some applicants could have difficulty perceiving their application’s errors:

  • Users with color deficiencies may have trouble distinguishing the error message from other text;
  • Low-vision or blind users may not see the page at all;
  • Users with cognitive disabilities might not recognize an error as an error;
  • And many other situations!

We wanted to design an error experience that considered all of these needs. For guidance, we followed the Web Content Accessibility Guidelines (WCAG), the de facto standard for accessibility on the web. Here are some of the features we built into the Form Field Error to offer an accessible experience.

Color Contrast

Low-vision or colorblind users often struggle with the contrast between a webpage’s text and background. Low-contrast text can be difficult to impossible to read for these users. For most text, the WCAG guidelines recommend a minimum 4.5:1 contrast ratio between the text color and its background color. We chose our error color to match this standard.

$hl-color-control-error-accent: #e91d00; // ✅ 4.53:1 contrast with white

For users who rely on their device’s High-Contrast Mode, you may need to supply alternate colors or styles that adhere to even higher contrast ratios. Microsoft has a great guide on developing for high-contrast needs in modern web applications.

// For macOS Safari 14.1+, iOS Safari 14.5+
@media (prefers-contrast: more) {
// Your high-contrast styles
}
// For Chrome, Edge, and Firefox
@media (forced-colors: active) {
// Your high-contrast styles
}

Non-Color Indication

Some people can’t perceive color at all, a form of colorblindness known as monochromacy or achromatopsia. For these users, color contrast alone may not be enough to distinguish an error message from, say, a field’s help text. That’s why it’s important to include some sort of error indication that doesn’t rely solely on color.

HDS displays a thick, solid bar next to each invalid form field to indicate an error state — a design inspired by the GOV.UK Design System, one of the most well-regarded examples of an accessible-first design system. The error bar unambiguously connects the error message to its question, so even those who can’t perceive color can tell that something’s wrong with their answer. It also makes the transition to an error state really apparent, which can benefit some people with cognitive disabilities who might be unlikely to notice smaller visual changes to a webpage.

// form-field.component.scss.hl-form-field {
// We use a pseudo-element of the Form Field container for the error bar
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
// Allow the offset to be configured for different layout contexts
left: -1 * var(--hl-form-field-error-bar-offset);
// Let the bar scale with font size, but never let it get too small to see clearly!
width: max(12px, 0.75rem);
background-color: transparent;
}
// Apply a transition effect to ease the error bar into view
&--invalid::before {
background-color: $hl-color-control-error-accent;
transition: background-color 150ms ease-in-out;
}
}

Other design systems place an icon next to the error message to mark it as an error, which has also proven effective, and can even be combined with the error bar to reinforce the error’s relationship to its question.

Non-Visual Affordances

Many users rely on screen readers, Braille displays, or other assistive technologies (AT) that convey the screen contents non-visually; for example, a screen reader like NVDA or VoiceOver will literally read the contents of the screen aloud. These technologies rely on the ARIA standard, which defines a set of attributes that applications can manage to provide semantic or contextual information about the state of the page.

ARIA is crucial for accessible error messages because users who rely on screen readers and other AT tend to navigate forms in “Forms Mode”, which only announces interactive elements like <input> and <select>, and the elements explicitly associated with those. To associate an error message with these elements, we need to reference the error’s ID in the aria-describedby attribute of its input:

<!-- my-form.component.html --><label for=”first-name”>First name</label>
<input id=”first-name” type=”text” aria-describedby=”first-name-error” />
<hl-form-field-error id=”first-name-error”><!-- errorMessage defined from context --></hl-form-field-error>

VoiceOver would announce the above example like so: First name, edit text; <errorMessage>.

Note that this announcement doesn’t actually say that the field is invalid. That’s because Angular forms disable native validation by default, so the screen reader has no explicit indication that the field is invalid. To make sure the invalid state is also announced, we have to set aria-invalid="true" on the <input>. HDS uses an Angular directive to do that:

export class HDSFormControlDirective {
constructor(private ngControl: NgControl) {}
// Note: Your definition of "invalid" may differ based on your desired UX patterns
@HostBinding(‘attr.aria-invalid’)
get invalid(): boolean | null {
return ((this.ngControl.touched || this.ngControl.dirty) && this.ngControl.invalid) || null;
}
}

Just to be safe, the Form Field Error also includes a visually hidden “Error:” prefix, invisible to sighted users but announced to AT users:

<!-- form-field-error.component.html --><span *ngIf=”errorMessage” class=”hl-a11y--visually-hidden”>Error: </span>{{ errorMessage }}

Last but not least, we need to account for when these messages are announced. If we’ve associated our error message with its input, then screen reader users will hear the error message when they focus the input. But if they fill out an input incorrectly and move on to the next field, their focus will leave the input, and they won’t hear the message! To address this, we can turn the error message into an ARIA live region by setting the attribute role="alert" on it. ARIA live regions announce changes to their contents automatically, so the user will hear the error as soon as it’s set in our Form Field Error component. We set role="alert" on both the field-level error messages and the form-level error summary to ensure that no one misses out on error messages at any point in the application experience.

By baking all of these considerations into HDS’s Form Field Error component, we’re able to offer accessible and easy-to-understand form error out of the box, helping applicants get the insurance they need.

See how else we’re making life better.

--

--