---
title: 'Text Inputs'
source: 'https://academia.sh/en/courses/accessible-patterns/text-inputs'
course: 'Accessible Component Patterns'
language: en
updated: '2026-08-19T05:19:51+00:00'
license: 'CC BY-SA 4.0'
---

# Text Inputs

The separate places the label, help text, error message, and placeholder occupy in the accessibility tree; computing the name and description, auditing the error binding, and the text field's keyboard contract.

In the button specification, the name came from a single source and did not change. The
catalog's search field and the borrowing form's membership number field are different in
this respect: four separate texts can stand around the field on screen — the label, the
help text, the error message, and the placeholder inside the field. All four appear to
the eye at the same distance.

In the accessibility tree, though, the four do not sit in the same place. One becomes the
field's **name**, one its **description**, one falls outside both, and one, when written
incorrectly, overrides the others. This lesson computes that distribution.

## The Problem the Field Solves

The text field is the control where the user types a value that **cannot be enumerated
in advance**: a search string, a membership number, a note for staff. The measure is
enumerability. If the "borrow duration" field in the borrowing form can only take three
values, it is not a text field; when the options can be enumerated, a selection control
or a dropdown list is used instead.

The second boundary is format. Asking for values like a date, time, color, or file as
free text makes the user guess the format and leaves the entire validation burden to the
code. Defined input types exist for these values.

## The Native Element First

The counterpart is the `input` element; `textarea` for multi-line text. The `type`
attribute determines both the field's role and its validation: `search` brings the
search-box role, `email` brings email-address validation, `number` brings the bounded
number-field role, `date` brings date selection.

Choosing the type does three things at once. The role in the accessibility tree changes;
the browser's built-in constraint validation engages; on touch devices, the keyboard
suited to the field opens. None of the three is written separately.

What kind of personal data a field collects can also be declared separately. The
`autocomplete` attribute states a value's **purpose** programmatically on fields like
member name, address, and email address; this is the counterpart of the 1.3.5 Identify
Input Purpose criterion, and it also lets the user autofill their own data.

## Where the Four Texts Sit in the Tree

**The label** is the field's name, and it is persistent. It is written with the `label`
element; the `for` attribute ties to the field's id, or the field is nested inside the
label.

**Help text** is format or constraint information: "The 8-digit number on the card." It
is not the field's name, it is its description, and it is tied with `aria-describedby`.
It is preferred to sit **above, not below**, the field on screen; text below the field
can end up under the keyboard that opens while the field is being filled in.

**The error message** is also a description, and it is tied with the same mechanism. It
is written **alongside** the help text, not in its place.

**The placeholder** is the example value sitting inside the field, and it disappears once
the field is filled in. It is last in the name computation order: it turns into the name
only if no other source exists. This does not mean it can be used in place of a label.

```js
// field.mjs — computing name, description, and state declarations in a text field

// Text-bearing nodes in the document (label and aria-describedby targets)
const DOCUMENT = {
  "member-help": "The 8-digit number on the card.",
  "member-error": "Membership number must be 8 digits.",
  "return-help": "At most 21 days from now can be selected.",
  "label:search": "Search the catalog",
  "label:member-no": "Membership number",
  "label:return": "Return date",
};

// Name order: aria-labelledby -> aria-label -> label element -> title -> placeholder
function name(a) {
  if (a["aria-labelledby"]) {
    const p = a["aria-labelledby"].split(" ").map((k) => DOCUMENT[k]).filter(Boolean);
    if (p.length > 0) return [p.join(" "), "aria-labelledby"];
  }
  if (a["aria-label"]) return [a["aria-label"], "aria-label"];
  if (DOCUMENT["label:" + a.id]) return [DOCUMENT["label:" + a.id], "label element"];
  if (a.title) return [a.title, "title (last resort)"];
  if (a.placeholder) return [a.placeholder, "placeholder (last resort)"];
  return ["", "no name"];
}

// Description order: aria-describedby -> title -> placeholder (if not already used for the name)
function description(a, nameSource) {
  if (a["aria-describedby"]) {
    const p = a["aria-describedby"].split(" ").map((k) => DOCUMENT[k]).filter(Boolean);
    if (p.length > 0) return [p.join(" "), "aria-describedby"];
  }
  if (a.title && nameSource !== "title (last resort)") return [a.title, "title"];
  if (a.placeholder && nameSource !== "placeholder (last resort)") return [a.placeholder, "placeholder"];
  return ["", "no description"];
}

function states(a) {
  const d = [];
  if (a.required || a["aria-required"] === "true") d.push("required");
  if (a["aria-invalid"] === "true") d.push("invalid");
  if (a.readonly) d.push("read-only");
  if (a.disabled) d.push("disabled");
  return d.length ? d.join(", ") : "-";
}

// --- Catalog search field and borrowing form fields -------------------------
const FIELDS = [
  ["label + placeholder",
   { id: "search", type: "search", placeholder: "title, author, or ISBN" }],
  ["placeholder only",
   { id: "search-2", type: "search", placeholder: "title, author, or ISBN" }],
  ["label + help text",
   { id: "member-no", required: true, "aria-describedby": "member-help" }],
  ["error replaces help",
   { id: "member-no", required: true, "aria-invalid": "true", "aria-describedby": "member-error" }],
  ["error added to help",
   { id: "member-no", required: true, "aria-invalid": "true",
     "aria-describedby": "member-help member-error" }],
  ["error link broken",
   { id: "member-no", required: true, "aria-invalid": "true",
     "aria-describedby": "member-help member-error-2" }],
  ["date, label + help",
   { id: "return", type: "date", "aria-describedby": "return-help" }],
  ["title only",
   { id: "note", title: "A note for staff" }],
  ["aria-label overrides label",
   { id: "member-no", "aria-label": "Number" }],
];

const G = 31;   // field column width

console.log("field".padEnd(G) + "accessible name".padEnd(28) + "name source".padEnd(27) + "state");
for (const [heading, a] of FIELDS) {
  const [n, k] = name(a);
  console.log(heading.padEnd(G) + (n === "" ? "(empty)" : '"' + n + '"').padEnd(28) +
    k.padEnd(27) + states(a));
}

console.log("\n" + "field".padEnd(G) + "description".padEnd(71) + "source");
for (const [heading, a] of FIELDS) {
  const [, k] = name(a);
  const [c, ck] = description(a, k);
  console.log(heading.padEnd(G) + (c === "" ? "(empty)" : '"' + c + '"').padEnd(71) + ck);
}

// --- Rule check ---------------------------------------------------------
const VISIBLE = { search: "Search the catalog", "member-no": "Membership number", return: "Return date" };

console.log("\nrule check:");
let findings = 0;
for (const [heading, a] of FIELDS) {
  const [n, k] = name(a);
  const [c] = description(a, k);
  const b = [];
  if (n === "") b.push("field is nameless");
  if (k.includes("last resort")) b.push("name comes from a last-resort source, no persistent label");
  const visible = VISIBLE[a.id];
  if (visible && n && !n.toLocaleLowerCase("en-US").includes(visible.toLocaleLowerCase("en-US")))
    b.push(`visible label ("${visible}") is not contained in the accessible name`);
  if (a["aria-describedby"]) {
    const broken = a["aria-describedby"].split(" ").filter((x) => !DOCUMENT[x]);
    if (broken.length) b.push("broken reference: " + broken.join(", "));
  }
  if (a["aria-invalid"] === "true" && !c.includes(DOCUMENT["member-error"]))
    b.push("invalid field, error message is not in the description");
  if (a["aria-invalid"] === "true" && a["aria-describedby"] &&
      !a["aria-describedby"].split(" ").includes("member-help"))
    b.push("error notification overrode the help text");
  for (const x of b) { findings++; console.log("  " + heading.padEnd(G) + x); }
}
console.log(`\n${FIELDS.length} fields, ${findings} findings`);
```

```
field                          accessible name             name source                state
label + placeholder            "Search the catalog"        label element              -
placeholder only               "title, author, or ISBN"    placeholder (last resort)  -
label + help text              "Membership number"         label element              required
error replaces help            "Membership number"         label element              required, invalid
error added to help            "Membership number"         label element              required, invalid
error link broken              "Membership number"         label element              required, invalid
date, label + help             "Return date"               label element              -
title only                     "A note for staff"          title (last resort)        -
aria-label overrides label     "Number"                    aria-label                 -

field                          description                                                            source
label + placeholder            "title, author, or ISBN"                                               placeholder
placeholder only               (empty)                                                                no description
label + help text              "The 8-digit number on the card."                                      aria-describedby
error replaces help            "Membership number must be 8 digits."                                  aria-describedby
error added to help            "The 8-digit number on the card. Membership number must be 8 digits."  aria-describedby
error link broken              "The 8-digit number on the card."                                      aria-describedby
date, label + help             "At most 21 days from now can be selected."                            aria-describedby
title only                     (empty)                                                                no description
aria-label overrides label     (empty)                                                                no description

rule check:
  placeholder only               name comes from a last-resort source, no persistent label
  error replaces help            error notification overrode the help text
  error link broken              broken reference: member-error-2
  error link broken              invalid field, error message is not in the description
  title only                     name comes from a last-resort source, no persistent label
  aria-label overrides label     visible label ("Membership number") is not contained in the accessible name

9 fields, 6 findings
```

## Reading the Findings

**The placeholder turns into the name, but turning into the name is not enough.** In the
second row, the search field's name becomes "title, author, or ISBN." The computation
accepts this, and the automated checker does not see a presence-rule violation either.
The defect is elsewhere: once the user starts typing into the field, the name is wiped
from the screen, and what the field wants stays invisible. The first row shows the
correct arrangement — the label is the name, the placeholder carries the description,
and each does its own job.

**The error message can override the help text.** In the fourth row, the
`aria-describedby` value has been **replaced** with the help text's id. The field's
description is now only the error message: the user hears the error but loses the
"8-digit" information. The fifth row shows the correct operation — it is added to the id
list, both ids stand together in the list, and the description becomes the combination
of both texts. This is the direct consequence of `aria-describedby` being a
**space-separated ID reference list**.

**A broken reference drops silently.** In the sixth row, the error node's id has been
written incorrectly. The computation skips that id, and the description comes only from
the help text: the field is reported as invalid, but the reason is announced nowhere.
This is the check's most valuable finding, because the error message is visibly present
on screen, and someone looking at it cannot see the defect.

**A name declaration overrides the visible label.** In the last row, the field's `label`
element says "Membership number," and its `aria-label` declaration says "Number." The
2.5.3 Label in Name criterion requires the visible label text to be contained within the
accessible name; here it is not.

## Keyboard Contract

| Key | Behavior |
|---|---|
| `Tab` | Brings focus to the field; the field is a single tab stop. |
| Typing keys | Changes the value. |
| `Home` / `End` | Moves the cursor to the start and end of the line. |
| Arrow keys | Moves the cursor within the text. |
| `Enter` | Performs implicit submission in a form with a single text field. |

The contract's real constraint is **which keys the field must not capture**. Arrow keys,
`Home`, `End`, and `Backspace` belong to text editing; a component that contains the
field cannot use these keys for its own navigation. This constraint is binding in
dropdown list and menu patterns: if a suggestion list tied to the search field is going
to use the arrow keys, it has to leave the key to the field whenever the cursor needs to
move within the text.

Also, seizing the field's focus programmatically — focusing the search field the moment
the page loads — cuts the keyboard user off from the navigation at the top of the page.
Focus is moved only after an action the user has initiated.

## Measurable Constraints

**3.3.2 Labels or Instructions** requires that a label or instruction be provided for
every field that requests input from the user. This is the criterion behind the
placeholder not substituting for the label.

**1.4.3 Contrast (Minimum)** also covers placeholder text: the placeholder has to carry
at least a 4.5:1 contrast ratio with the field surface. Fading the placeholder is a
common design preference, and it removes the criterion directly.

**1.4.11 Non-text Contrast** covers the field's border: the boundary showing where the
field starts and ends has to separate from the page surface by at least 3:1. In fields
built with only an underline, this measure is computed over the single edge.

**1.4.4 Resize Text** requires that text be scalable up to 200 percent; a fixed-height
field box clips the text at that scale.

**3.3.1 Error Identification** requires the error to be conveyed in text. A red border
alone is not a notification; the `aria-invalid` state and the tied error message are the
notification itself.

## Common Mistake

**The label being visually removed and moved into the placeholder.** Catching it is the
check's second row: every field whose name source is flagged as "last resort" is in this
state. If the label genuinely should not be visible — like the standalone search field
above the results table — the element itself stays in the document and is only hidden
from the screen; it is not removed from the tree.

**The error message being shown only on screen.** A text node added below the field is
not tied to the field in the tree unless the `aria-describedby` binding is established.
Catching it means checking whether every field flagged as invalid has the error text
within its description; the check's last two rules do this.

**Requiredness being conveyed only with an asterisk.** The asterisk next to the label is
a visual mark; in the tree, requiredness comes from the `required` attribute. What the
asterisk means has to be written separately, and the attribute has to be present in every
case.

## Summary

- A text field is used when the value cannot be enumerated in advance; a selection
  control or a dropdown list is chosen for enumerable values.
- The `type` attribute determines the role, built-in validation, and touch keyboard
  together; `autocomplete` declares the field's input purpose programmatically.
- The label goes to the name; the help text and error message go to the description.
  `aria-describedby` is an ID reference list, and the error message is added alongside
  the help text, not in its place.
- The placeholder is last in the name computation order; turning into the name does not
  make it a label, because it is wiped from the screen once the field is filled in.
- A broken ID reference drops silently, and the field appears invalid without a reason;
  the check catches this despite the error message being visible on screen.
- Arrow keys, `Home`, and `End` belong to text editing; a component that contains the
  field cannot use these keys for its own navigation.

## Next Step

A text field carries a single value, and that value's name is the field's name. The
"Notification method" options in the borrowing form, by contrast, consist of three
separate controls, and all three share one question: "How should the notification be
sent?" That question is not any control's name — it is the **group's** name. The next
lesson takes up the checkbox, the radio button, and the switch: it specifies how the
group is named, which state declaration each of the three controls carries, and why only
one of them has arrow keys in its keyboard contract.
