Skip to content
academia.sh

Lesson 19 / 22

Accessible Error Presentation

Programmatically associating the error message with the field, announcing invalidity, building an error summary ordered by document position, moving focus, and the moment at which the message is written.

Contents

The previous lesson produced an error code and a parameter for every field. The next question is not how these appear on the screen: an error message that only works when seen is unfinished. When an observer navigating with a screen reader submits the measurement entry form, they do not see the red outline; text written below the field is not automatically read together with that field.

This lesson covers making the error perceivable: which element the message is tied to, how a field’s invalidity is announced, where focus goes when a submission is rejected, and at what moment the message is written.

What an Error Is for a User Who Cannot See It

Visual presentation uses three cues: color, position, and icon. All three are insufficient on their own.

Color cannot be the only indicator. WCAG success criterion 1.4.1 says this directly: information cannot be conveyed by color alone. A red outline is just an ordinary outline to a user without color perception. Text is needed alongside the color.

Position is not a programmatic link. A message sitting right below the field is a relationship built by the eye. If there is no link between the two elements in the accessibility tree, a user who focuses the field never hears the message at all. The link must be built explicitly.

Announcing an error and describing it are separate success criteria. WCAG 3.3.1 requires that the error be identified in text; 3.3.3 requires that, where possible, a suggestion for correction be offered. “Invalid value” satisfies the first but not the second; “Enter a value between −60 and 60” satisfies both. This is why the previous lesson’s error parameters are written into the message.

Tying the Message to the Field

The link is built with the field’s accessible description list. This mechanism, defined in the Web Fundamentals and HTML course, sets which texts get read after the label once the field is focused. If a field has both a hint and an error, both enter the list, and the list’s order is the reading order.

// error-markup.mjs — associating field, hint, error, and summary
const MESSAGES = {
  required:      () => "This field is required.",
  not_a_number:  () => "Enter a number; you can use a comma as the decimal separator.",
  out_of_range:  (p) => `Enter a value between ${p.min} and ${p.max}.`,
  in_future:     () => "The measurement date cannot be in the future.",
};

// Fields are defined in document order; that order drives the focus decision.
const FIELDS = [
  { name: "station", label: "Station", hint: null },
  { name: "date",    label: "Measurement date", hint: "Day-month-year format." },
  { name: "value",   label: "Measurement value", hint: "Degrees Celsius." },
  { name: "note",    label: "Note", hint: null },
];

function fieldMarkup(field, error) {
  const id = `field-${field.name}`;
  const hintId = field.hint ? `${id}-hint` : null;
  const errorId = error ? `${id}-error` : null;
  // Order matters: the hint is read first, then the error.
  const describedBy = [hintId, errorId].filter(Boolean).join(" ");

  const lines = [`<label for="${id}">${field.label}</label>`];
  lines.push(
    `<input id="${id}" name="${field.name}"` +
    (describedBy ? ` aria-describedby="${describedBy}"` : "") +
    (error ? ` aria-invalid="true"` : "") + ">");
  if (field.hint) lines.push(`<p id="${hintId}">${field.hint}</p>`);
  if (error) lines.push(`<p id="${errorId}">${MESSAGES[error.code](error.param)}</p>`);
  return lines.join("\n");
}

function summary(errors) {
  // Ordered by the field's position in the document, not by detection order.
  const ordered = FIELDS
    .map((f) => [f, errors.find((e) => e.path === f.name)])
    .filter(([, e]) => e);
  const items = ordered.map(([f, e]) =>
    `  <li><a href="#field-${f.name}">${f.label}: ${MESSAGES[e.code](e.param)}</a></li>`);
  return [
    `<div role="alert" tabindex="-1" id="error-summary">`,
    `  <h2>${ordered.length} fields need correction</h2>`,
    "  <ul>", ...items, "  </ul>", "</div>",
  ].join("\n");
}

// Error list from the schema (detection order differs from document order).
const ERRORS = [
  { path: "value", code: "out_of_range", param: { min: -60, max: 60 } },
  { path: "date", code: "in_future" },
];

console.log(summary(ERRORS));
console.log("");
for (const field of FIELDS) {
  console.log(fieldMarkup(field, ERRORS.find((e) => e.path === field.name)));
}

// --- Focus target -------------------------------------------------------------
const firstInvalid = FIELDS.find((f) => ERRORS.some((e) => e.path === f.name));
console.log("\nerror count :", ERRORS.length);
console.log("focus target:", ERRORS.length > 1 ? "#error-summary" : `#field-${firstInvalid.name}`);
console.log("if only one error:", `#field-${firstInvalid.name}`);
<div role="alert" tabindex="-1" id="error-summary">
  <h2>2 fields need correction</h2>
  <ul>
  <li><a href="#field-date">Measurement date: The measurement date cannot be in the future.</a></li>
  <li><a href="#field-value">Measurement value: Enter a value between -60 and 60.</a></li>
  </ul>
</div>

<label for="field-station">Station</label>
<input id="field-station" name="station">
<label for="field-date">Measurement date</label>
<input id="field-date" name="date" aria-describedby="field-date-hint field-date-error" aria-invalid="true">
<p id="field-date-hint">Day-month-year format.</p>
<p id="field-date-error">The measurement date cannot be in the future.</p>
<label for="field-value">Measurement value</label>
<input id="field-value" name="value" aria-describedby="field-value-hint field-value-error" aria-invalid="true">
<p id="field-value-hint">Degrees Celsius.</p>
<p id="field-value-error">Enter a value between -60 and 60.</p>
<label for="field-note">Note</label>
<input id="field-note" name="note">

error count : 2
focus target: #error-summary
if only one error: #field-date

Four decisions show up in the generated markup.

The accessible description list carries both the hint and the error. Dropping the hint from the list once an error appears is a common mistake; the user hears the error but never learns the expected format. The two stay together, and the hint is read first.

Invalidity is announced separately. Reading the message is not the same as announcing that the field is invalid. aria-invalid="true" writes the field’s state to the accessibility tree; it is used alongside the message text, not in its place. There is another attribute that does a similar job — aria-errormessage, which points directly at the error message — but it only carries meaning once an invalidity announcement is given, while the accessible-description reference works in every case. The robust path is to build on the accessible-description reference.

A field with no error carries no such attribute. The first and last fields have no aria-invalid written; writing aria-invalid="false" for a valid field is also correct but unnecessary. What matters is that the attribute is removed once the error is fixed; if it stays, the user hears a corrected field as still invalid.

The summary is ordered by document position. The error list coming from the schema started with value, but in the summary date came first. When the user reads down the summary’s items, they should also move down the form in the same order; detection order is an implementation detail and must not leak onto the screen.

The Summary Box and Moving Focus

When submission is rejected, the user needs to know two things: that the submission did not go through, and what needs fixing. If no part of the page appears to have changed — especially in a long form, where the errors ended up outside the viewport — the user assumes the button is not working.

The error summary sits at the top of the form, states the error count, and each item links to its field. Being a link matters: a user navigating by keyboard selects the item and goes straight to the field. The item’s text carries the field’s label together with the message; if only the message were written, it would not be clear which field is being referred to.

Focus moves to the summary box. For the box to be focusable, it needs to be marked as a focus target that does not join the tab order — the tabindex="-1" in the output does this; the element can be focused programmatically but does not take part in the tab sequence.

The behavior splits into two based on the error count. With a single error, moving focus directly to that field saves the user a step. With more than one error, moving it to the summary box gives the user the full picture first. The output’s last three lines compute this decision.

Moving focus has a limit: taking it somewhere the user does not expect is itself a disruption. The rule is that focus is moved only in response to the user’s own action. Pressing the submit button is such an action; a request completing in the background is not.

The Moment of Announcement

Even when the markup is correct, when the message appears is a separate decision. Saying “invalid” on every keystroke while the user is typing the measurement value declares an unfinished input to be an error, and makes typing harder.

// display-timing.mjs — when the error message is written
function isShown({ hasError, touched, submitAttempted, wasShown }) {
  if (!hasError) return false;                // nothing to show without an error
  if (submitAttempted) return true;           // everything shows after submission
  if (touched) return true;                   // the field was left
  return wasShown;                            // once shown, keep tracking it
}

// The answer is always "no" without an error; the table only counts an invalid field.
const CASES = [];
for (const touched of [false, true])
  for (const submitAttempted of [false, true])
    for (const wasShown of [false, true])
      CASES.push({ hasError: true, touched, submitAttempted, wasShown });

const yesNo = (b) => (b ? "yes" : "no");
console.log("touched  submitted  before  →  shown");
for (const c of CASES) {
  console.log(
    yesNo(c.touched).padEnd(9) + yesNo(c.submitAttempted).padEnd(11) +
    yesNo(c.wasShown).padEnd(8) + "   " + yesNo(isShown(c)));
}

// The user's actual journey: no error while typing, one on leaving, gone once fixed.
console.log("\nstep by step through one field's life");
const JOURNEY = [
  ["typed '-' into an empty field", { hasError: true,  touched: false, submitAttempted: false }],
  ["typed '-4'",                    { hasError: false, touched: false, submitAttempted: false }],
  ["field cleared",                 { hasError: true,  touched: false, submitAttempted: false }],
  ["field left",                    { hasError: true,  touched: true,  submitAttempted: false }],
  ["came back, typed '9'",          { hasError: false, touched: true,  submitAttempted: false }],
  ["made it '900'",                 { hasError: true,  touched: true,  submitAttempted: false }],
];
let shownBefore = false;
for (const [name, partial] of JOURNEY) {
  const shown = isShown({ ...partial, wasShown: shownBefore });
  shownBefore = shown;
  console.log(`  ${name.padEnd(31)} error=${yesNo(partial.hasError).padEnd(4)} → ${yesNo(shown)}`);
}
touched  submitted  before  →  shown
no       no         no         no
no       no         yes        yes
no       yes        no         yes
no       yes        yes        yes
yes      no         no         yes
yes      no         yes        yes
yes      yes        no         yes
yes      yes        yes        yes

step by step through one field's life
  typed '-' into an empty field   error=yes  → no
  typed '-4'                      error=no   → no
  field cleared                   error=yes  → no
  field left                      error=yes  → yes
  came back, typed '9'            error=no   → no
  made it '900'                   error=yes  → yes

The table’s second row is the core of the rule: even when the field has not been left yet and no submission has been attempted, an error that was shown once keeps being shown. The consequence appears in the last two lines of the second output block — once a field has been found invalid, the message updates instantly as the user types; it disappears once fixed, and returns once broken again.

The behavior in the first three rows is deliberate too. When the user types a minus sign into an empty field, the value is already invalid, but no message appears; the input is still in progress. The message first appears once the field is left.

This rule explains why the touched set and the submitCount counter from the previous lesson are kept separate. Both are inputs to the question “can I show this field,” and each marks a different moment.

Live Region or Focus?

There are two ways for text that appears on the page to be read by a screen reader: moving focus there, or placing the text inside a live region. WCAG success criterion 4.1.3 requires that status messages that do not take focus be announced too.

The distinction is made with one question: does the user need to do something right now?

Moving focus directs the user and interrupts their flow. It is appropriate when a submission is rejected; the user is already waiting for a result and needs to do something to continue. The summary box in the output carries role="alert"; this role marks the box’s content to be announced the moment it appears, and is used together with moving focus.

A live region gives information without interrupting the user’s flow. In a form validated field by field, focus is not moved for every message; messages accumulate in a politely-prioritized region and are read once the user stops typing.

Over-announcing is a real defect. A live region updated on every keystroke keeps the screen reader talking constantly and stops the user from hearing what they are typing. That is why field-by-field validation results are throttled with debounce, while the submission result is delivered as a single, decisive announcement.

Summary

  • Color cannot be the only indicator and position is not a programmatic link; the message is explicitly attached to the field through an accessible-description reference.
  • The accessible description list carries both the hint and the error in order; the invalidity announcement does not replace the message, it is given alongside it, and is removed once the error is fixed.
  • The error summary is ordered by document position, states the error count, and each item links to its field.
  • Focus moves directly to the field when there is a single error, and to the summary box when there is more than one; moving it happens only in response to the user’s own action.
  • The message is first written once the field is left or a submission is attempted; once written, it updates on every change.
  • Announcements that do not interrupt focus are given through a live region; a region updated on every keystroke produces over-announcing and is throttled with debounce.

Next Step

The measurement entry form now holds its value, applies its rules identically on both sides, and presents its errors so that everyone can perceive them. One question the form asks remains: who is entering this measurement? Not everyone can write to North Slope’s records; the observer must be recognized first. The next lesson covers the client side of the identity flow — where the token issued by the server lives in the browser, and which risks that decision opens and which it closes.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close