Français

Beware of the Leopard — Part 2: Listening to the answer

Table of contents

Listening to the answers

Part 1 covered asking the right questions. Labels, types, autocomplete, grouping. Your form now knows how to ask the right things, the right way.

But a conversation isn't an interrogation. Asking questions isn't enough. You need to listen to the answers, signal when something's wrong, and guide toward correction. That's where most forms fail.

A field turns red with no explanation. An "Error" message appears without saying where or why. The form clears itself after a failed submission. The user starts over from scratch without knowing what went wrong.

This article covers the second half of the conversation: validate, signal, correct.

Validate at the right time

When you validate a field changes everything about the experience.

Validating on page load, all required fields show errors before the user has typed anything. That's exactly what :invalid does in CSS. It applies as soon as the DOM is built, not after interaction.

validation-timing.css
/* Triggers on page load — user sees errors immediately */
input:invalid {
  border-color: var(--color-error);
}
 
/* Triggers only after user interaction */
input:user-invalid {
  border-color: var(--color-error);
}

:user-invalid only triggers after the user has interacted with the field — typing, clearing, or attempting submission. It's the difference between welcoming someone and attacking them.

But validation timing isn't just a CSS question. It's a design conversation question:

  • During input (real-time) : useful for strict formats (email, phone), as long as you don't show the error before the user has finished typing. An email field that screams "invalid" after the first letter is hostile.
  • On blur : the field is validated when the user moves to the next one. Often the best compromise. Early enough to correct immediately, late enough not to interrupt input.
  • On submission : all fields validated at once. Necessary as a safety net, but insufficient alone. The user discovers 5 errors simultaneously without knowing where to start.

The most robust pattern combines all three: :user-invalid in CSS for immediate visual feedback, validation on blur for help messages, and validation on submission as a final safety net with an error summary.

Errors that help

An error message has one mission: enable the user to correct. If it doesn't say what to fix and how, it's useless.

"Error" says nothing. "Invalid field" says almost nothing. "This field expects an email address in the format name@domain.com" says everything.

The mechanism relies on two attributes:

error-message.html
<label for="email">Email address</label>
<input
  id="email"
  type="email"
  required
  aria-describedby="email-error"
>
<p id="email-error" class="error-message"></p>
error-message.js
// Toggle the state and fill the message at validation time
function setFieldError(field, message) {
  const errorNode = document.getElementById(`${field.id}-error`);
  field.setAttribute('aria-invalid', 'true');
  errorNode.textContent = message;
}
 
function clearFieldError(field) {
  const errorNode = document.getElementById(`${field.id}-error`);
  field.removeAttribute('aria-invalid');
  errorNode.textContent = '';
}
error-message.css
input:user-invalid {
  border-color: var(--color-error);
}
 
.error-message {
  color: var(--color-error);
  font-size: var(--font-size-sm);
}
 
.error-message:not(:empty)::before {
  content: "⚠";
  margin-inline-end: var(--space-1);
}

Two traps to avoid. First, aria-invalid="true" must not be hard-coded in the HTML: copied as-is, the field would be flagged invalid on page load. It's set dynamically, by JavaScript, at validation time. Second, no aria-live="polite" on the message itself: the message is already the target of aria-describedby, and the error summary carries role="alert". Combining both on the same element risks a double announcement. The announcement comes from the summary (immediate) and from the description (read when the field receives focus). If you insist on an immediate announcement during blur validation, test it with a screen reader before claiming it works.

aria-invalid="true" tells assistive technology the field contains an error. aria-describedby links the field to the message explaining the error. The screen reader announces: "Email address, invalid, This field expects an email address in the format name@domain.com."

The visual signal follows the same principle as colors that must never be alone. Border, text, and icon.

Some rules for error messages:

  • Say what's wrong. "Enter your 10-digit phone number", not "This field is required"
  • Say how to fix it. "Expected format: 555-123-4567", not "Invalid format"
  • Stay near the field. The message must be visually adjacent to the field in error
  • Don't disappear too quickly. The message stays visible until the error is corrected

These field-level messages are essential, but they're not enough alone for long forms. The next section covers the error summary — the big picture after submission.

The error summary

Field-level messages help correct one error at a time. But when a 10-field form is submitted with 4 errors, the user needs an overview — especially if they can't see the whole page.

The error summary is a block that appears at the top of the form after a failed submission. It lists all errors, with a link to each affected field:

error-summary.html
<section role="alert" id="error-summary">
  <h2 id="error-summary-title" tabindex="-1">4 errors to correct</h2>
  <ul>
    <li><a href="#email">Email address: add an @ and a domain (e.g. name@domain.com)</a></li>
    <li><a href="#phone">Phone: enter your 10-digit number</a></li>
    <li><a href="#zip">Postal code: enter 5 digits (e.g. 90210)</a></li>
    <li><a href="#terms">Terms: check the box to continue</a></li>
  </ul>
</section>
error-summary.js
// After failed submission, focus the summary heading
document.getElementById('error-summary-title').focus();

Three things happen when submission fails:

  1. The summary appears at the top of the form listing all errors
  2. Focus is sent to the summary heading (tabindex="-1" makes it programmatically focusable) : the screen reader announces "4 errors to correct"
  3. Each error is a link to the affected field : one click or Enter and the user is on the field to correct

The two mechanisms deliberately live on different nodes. role="alert" is on the <section>, which contains the heading and the list: during client-side validation, the live region is inserted into the page, detects the change and announces its whole content. Focus, meanwhile, goes to the <h2>: during server-side validation, the summary is already present at page load, the live region has seen nothing change and stays silent, and it's the focus that announces the heading. Each covers the case where the other stays quiet.

This isn't a theoretical precaution. With role="alert" and tabindex="-1" on the same element, I observed under VoiceOver that Chrome reads the heading twice. Still on a single element, under Safari, programmatic focus alone announces nothing: the only announcement heard came from the alert role, not the focus. The alert role alone announces in both browsers. These observations are limited to that configuration, macOS with VoiceOver, on Safari and Chrome. I haven't yet tested under NVDA or JAWS, nor this exact markup combining both mechanisms. GOV.UK separates them the same way, with an intermediate container; here the heading itself serves as the focus target, which avoids one more element. The pattern remains to be verified with a screen reader.

The summary doesn't replace field-level messages : it complements them. The user reads the summary to understand the scope of the problem, clicks a link to go to the first field in error, corrects it, then uses Tab to find the next errored field via its adjacent message.

For short forms (2-3 fields), the summary is optional, adjacent messages suffice. For long forms, it becomes essential.

Custom components

Native HTML offers <select>, <input type="date">, <input type="range">, <details>, <datalist>. They're accessible by default: keyboard navigable, correctly announced by screen readers, compatible with autocomplete and native validation.

And yet, most projects replace them with custom components. A <div> that looks like a <select>. A JavaScript calendar replacing <input type="date">. A homemade combobox reinventing <datalist>.

Why? Because native components are hard to style. <input type="date"> is the most telling example: its popup is a native OS widget, not a web element. In 2026, you still can't change its colors, layout, or typography. Its rendering varies between Chrome, Firefox, and Safari. WebKit pseudo-elements don't work everywhere. That's a valid reason to replace it. But it comes at a cost.

A custom calendar must reproduce everything the native one does for free:

  • Keyboard navigation (Tab to enter, arrows to move between days, Enter to select, Escape to close)
  • Screen reader announcement (role, current date, displayed month, selected day)
  • Real-time updates (aria-selected, aria-current="date", aria-label on each day)
  • Focus management (confinement in the open calendar, return to field on close)

Here's the minimal structure for an accessible calendar, and it's already substantial:

custom-datepicker.html
<label for="departure">Departure date</label>
<input
  id="departure"
  type="text"
  autocomplete="off"
  placeholder="dd/mm/yyyy"
>
<button
  aria-label="Choose departure date"
  aria-haspopup="dialog"
  aria-expanded="false"
>📅</button>
 
<dialog aria-label="Choose a date">
  <header>
    <button>Previous month</button>
    <h2 aria-live="polite">April 2026</h2>
    <button>Next month</button>
  </header>
  <table role="grid" aria-label="April 2026">
    <thead>
      <tr>
        <th scope="col" aria-label="Monday" title="Monday">M</th>
        <th scope="col" aria-label="Tuesday" title="Tuesday">T</th>
        <th scope="col" aria-label="Wednesday" title="Wednesday">W</th>
        <th scope="col" aria-label="Thursday" title="Thursday">T</th>
        <th scope="col" aria-label="Friday" title="Friday">F</th>
        <th scope="col" aria-label="Saturday" title="Saturday">S</th>
        <th scope="col" aria-label="Sunday" title="Sunday">S</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td tabindex="-1" aria-label="1 April 2026">1</td>
        <td tabindex="-1" aria-label="2 April 2026">2</td>
        <!-- ... -->
        <td tabindex="0" aria-selected="true" aria-current="date" aria-label="22 April 2026">22</td>
        <!-- ... -->
      </tr>
    </tbody>
  </table>
</dialog>

And the JavaScript is still missing: keyboard navigation between days (arrows), between months (Page Up/Down), between years (Shift + Page Up/Down), return to field on selection, Escape to close, dynamic aria-label updates. Each behavior must be manually implemented. The native <input type="date"> does it in zero lines of code.

Before building a custom component, ask yourself three questions:

  1. Does the native element suffice? An <input type="date"> with a good label + min/max + validation combination often covers the need. A <datalist> combined with an <input> gives a native combobox.
  2. Can the design adapt? Sometimes, accepting the native element's limitations costs less than maintaining an accessible custom component.
  3. If I have no choice, am I following the ARIA pattern? The ARIA Authoring Practices Guide documents each pattern (combobox, dialog, grid, tabs, accordion) with the expected keyboard interactions, roles, and states. Not following them is reinventing the wheel as a square.

ARIA is covered in two articles: the poison and the scalpel. The golden rule: if a native HTML element does the job, use it.

The overall criteria

WCAG covers validation, errors, and interactive components:

  • 2.1.1 Keyboard — all functionality is operable through a keyboard (Level A)
  • 2.4.6 Headings and Labels — headings and labels describe topic or purpose. "Submit" is vague, "Confirm order" is explicit (Level AA)
  • 3.3.1 Error Identification — if an input error is detected, the item in error is identified and described in text (Level A)
  • 3.3.3 Error Suggestion — if an input error is detected and suggestions are known, they are provided (Level AA)
  • 4.1.2 Name, Role, Value — for all UI components, name, role, and state are programmatically determinable (Level A)
  • 4.1.3 Status Messages — status messages are programmatically determinable without receiving focus (Level AA)

WCAG 2.1 AA is the technical standard referenced, directly or indirectly, by accessibility legislation worldwide — the ADA in the US, the Equality Act in the UK, the EAA in Europe, the ACA in Canada.

Part 3 will cover error prevention for financial/legal data (WCAG 3.3.4) and multi-step forms.

Sources