Accessibility pattern · Form inputs

Text field

A text field is only as clear as its label: visible, above the input, and tied to it with for and id. Hints and errors join the field's description through aria-describedby, so they are read as the field is reached, while autocomplete and inputmode bring the right autofill and keyboard.

WCAG criteria
7
Keyboard rules
3
Checked with
axe, keyboard and the inspector

Live demo

Try it

Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.

Book a pickup

A courier collects the parcel from your door.

We send the tracking link here.

10 digits. The courier calls before arriving.

For transit insurance.

In kilograms, up to 30.

Booking details

Read-only You can focus and copy it, and it is sent with the form.

Disabled Skipped by Tab and left out of the form.

The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.

Keyboard

Keys it answers to

Every action works without a pointer.

KeyWhat it does
TabMoves to the next field. The read-only booking number is a stop; the disabled city is skipped.
ShiftTabMoves to the previous field.
EnterSends the form from any field. If something is wrong, focus moves to the first field with an error.

Screen readers

What it announces

Written from the roles, names and states in the markup.

WhenExpected announcement
Focus reaches Full nameFull name, edit text, required
Focus reaches EmailEmail, edit text, required. We send the tracking link here.
Focus reaches Declared valueDeclared value in rupees, edit text, required. For transit insurance.
Focus reaches Parcel weightParcel weight, edit text, required. In kilograms, up to 30.
Book pickup is pressed with Full name and Parcel weight emptyFull name, edit text, required, invalid entry. Enter your full name
The status beside the button updates2 fields need attention.
Focus reaches the booking numberBooking number, edit text, read only, PK-48213. Read-only. You can focus and copy it, and it is sent with the form.
Every field is valid and the form is sentPickup booked. Your tracking link is on its way.

These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.

Code

Copy the code

The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.

The markup

<form class="ap-text-field" data-ap-text-field novalidate aria-labelledby="tf-form-name">
  <div class="ap-text-field__top">
    <h3 class="ap-text-field__name" id="tf-form-name">Book a pickup</h3>
    <p class="ap-text-field__sub">A courier collects the parcel from your door.</p>
  </div>

  <div class="ap-text-field__grid">
    <div class="ap-field ap-text-field__item ap-text-field__item--wide">
      <label class="ap-label ap-text-field__label" for="tf-name">Full name <span class="ap-text-field__req" aria-hidden="true">Required</span></label>
      <input class="ap-input" id="tf-name" name="name" type="text" autocomplete="name" spellcheck="false" required />
      <p class="ap-error ap-text-field__error" id="tf-name-error" hidden><svg class="ap-text-field__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-text-field__msg"></span></p>
    </div>

    <div class="ap-field ap-text-field__item">
      <label class="ap-label ap-text-field__label" for="tf-email">Email <span class="ap-text-field__req" aria-hidden="true">Required</span></label>
      <p class="ap-hint" id="tf-email-hint">We send the tracking link here.</p>
      <input class="ap-input" id="tf-email" name="email" type="email" autocomplete="email" spellcheck="false" required aria-describedby="tf-email-hint" />
      <p class="ap-error ap-text-field__error" id="tf-email-error" hidden><svg class="ap-text-field__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-text-field__msg"></span></p>
    </div>

    <div class="ap-field ap-text-field__item">
      <label class="ap-label ap-text-field__label" for="tf-tel">Mobile number</label>
      <p class="ap-hint" id="tf-tel-hint">10 digits. The courier calls before arriving.</p>
      <input class="ap-input" id="tf-tel" name="tel" type="tel" autocomplete="tel" aria-describedby="tf-tel-hint" />
      <p class="ap-error ap-text-field__error" id="tf-tel-error" hidden><svg class="ap-text-field__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-text-field__msg"></span></p>
    </div>

    <div class="ap-field ap-text-field__item">
      <label class="ap-label ap-text-field__label" for="tf-value"><span>Declared value<span class="ap-text-field__vh"> in rupees</span></span> <span class="ap-text-field__req" aria-hidden="true">Required</span></label>
      <p class="ap-hint" id="tf-value-hint">For transit insurance.</p>
      <div class="ap-text-field__affixed ap-text-field__affixed--pre">
        <span class="ap-text-field__affix" aria-hidden="true">₹</span>
        <input class="ap-input" id="tf-value" name="value" type="text" inputmode="numeric" required aria-describedby="tf-value-hint" />
      </div>
      <p class="ap-error ap-text-field__error" id="tf-value-error" hidden><svg class="ap-text-field__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-text-field__msg"></span></p>
    </div>

    <div class="ap-field ap-text-field__item">
      <label class="ap-label ap-text-field__label" for="tf-weight">Parcel weight <span class="ap-text-field__req" aria-hidden="true">Required</span></label>
      <p class="ap-hint" id="tf-weight-hint">In kilograms, up to 30.</p>
      <div class="ap-text-field__affixed ap-text-field__affixed--post">
        <input class="ap-input" id="tf-weight" name="weight" type="text" inputmode="decimal" required aria-describedby="tf-weight-hint" />
        <span class="ap-text-field__affix" aria-hidden="true">kg</span>
      </div>
      <p class="ap-error ap-text-field__error" id="tf-weight-error" hidden><svg class="ap-text-field__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-text-field__msg"></span></p>
    </div>
  </div>

  <div class="ap-text-field__panel">
    <h4 class="ap-text-field__group">Booking details</h4>
    <div class="ap-text-field__pair">
      <div class="ap-field ap-text-field__item">
        <label class="ap-label ap-text-field__label" for="tf-booking">Booking number</label>
        <input class="ap-input ap-text-field__fixed" id="tf-booking" name="booking" type="text" value="PK-48213" readonly aria-describedby="tf-booking-note" />
        <p class="ap-text-field__note" id="tf-booking-note"><span class="ap-text-field__tag"><svg class="ap-text-field__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>Read-only</span> <span>You can focus and copy it, and it is sent with the form.</span></p>
      </div>
      <div class="ap-field ap-text-field__item">
        <label class="ap-label ap-text-field__label" for="tf-city">Pickup city</label>
        <input class="ap-input ap-text-field__fixed" id="tf-city" name="city" type="text" value="Pune" disabled aria-describedby="tf-city-note" />
        <p class="ap-text-field__note" id="tf-city-note"><span class="ap-text-field__tag"><svg class="ap-text-field__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m5.7 5.7 12.6 12.6"/></svg>Disabled</span> <span>Skipped by Tab and left out of the form.</span></p>
      </div>
    </div>
  </div>

  <div class="ap-text-field__foot">
    <p class="ap-text-field__status" role="status">
      <svg class="ap-text-field__icon ap-text-field__icon--ok" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8 12.5 2.5 2.5L16 9.5"/></svg>
      <svg class="ap-text-field__icon ap-text-field__icon--bad" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg>
      <span class="ap-text-field__say" data-ap-say></span>
    </p>
    <button type="submit" class="ap-btn ap-btn--primary">Book pickup</button>
  </div>
</form>

WCAG 2.2

What it meets

The success criteria this pattern takes care of, and how.

  • 1.3.1 Info and Relationships Level A

    Each label is tied to its input with for and id, and hints and errors join the input's aria-describedby, so the relationships are in the code, not only in the layout.

  • 1.3.5 Identify Input Purpose Level AA

    Name, email and mobile number carry autocomplete tokens, so browsers and password managers can fill them in.

  • 1.4.1 Use of Color Level A

    An error is marked with an icon, a message and a thick edge on the field, never by a red border alone.

  • 3.3.1 Error Identification Level A

    Each field with a problem is marked aria-invalid and gets its own message, in text, straight under it.

  • 3.3.2 Labels or Instructions Level A

    Every field has a visible label above it, required fields say Required in words, and units and formats are given before typing starts.

  • 3.3.3 Error Suggestion Level AA

    Messages say how to put the problem right, with an example where the format matters, like 2.5 for a weight.

  • 4.1.2 Name, Role, Value Level A

    Required, invalid and read-only states come from the input itself, so a screen reader reports them with the field's name.

Usage

When to use it

Use it

  • Short free-text answers: names, email addresses, phone numbers, amounts and references.
  • Values with a unit or a currency, where an adornment helps people check what they typed.

Use something else

  • A choice from a short, known list: use radio buttons or a select.
  • Long answers: use a text area, with a counter if there is a limit.
  • Quantities people step up and down: a number input with buttons suits that better.

Common failures

How it usually goes wrong

  • A placeholder instead of a label

    Placeholder text disappears as soon as typing starts, is usually too faint to read, and is not a dependable name. Every field here has a real label above it.

  • A red border as the only sign of an error

    People who cannot tell red from grey see nothing wrong. Each error here has an icon and a message, and the field gets a thick edge as well.

  • An error that is not tied to its field

    A message that sits near the input but is not in its aria-describedby is never heard by someone tabbing through the form. The script adds the error's id when it appears.

  • type=number for things that are not quantities

    Phone numbers, PINs and amounts are not values you step through: type=number drops leading zeros, changes on scroll and rejects commas. inputmode brings up the number keypad without those problems.

  • A unit drawn only beside the box

    A ₹ or kg painted next to the input is not part of its name or description. Say the unit in the label or the hint, as the two fields here do.

  • Disabled where read-only was meant

    A disabled field cannot take focus, so keyboard and screen reader users never hear its value or its explanation, and it is left out of the data sent. Use readonly for values people need to read.

Notes

Building it

  • The form has novalidate so the script's messages replace the browser's bubbles; the required attribute stays, so the state is still exposed.
  • Errors appear when the form is sent, not while people type. Once shown, each one clears the moment its field is fixed.
  • Keep the hint in aria-describedby and add or remove only the error's id, so the hint is never lost when the error goes.
  • The word Required is aria-hidden because the required attribute already reports it; showing it in text helps everyone who would miss an asterisk.
  • The ₹ unit is spoken through visually hidden text in the label, and kg through the hint. When Enter is pressed in the first broken field, focus is already there, so the status beside the button also reads that field's message.

Sources: WAI Tutorials: Labeling controls · WAI Tutorials: User notifications · HTML: autofill field names

Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction