Field

User Experience (UX)

How a field behaves for every person and setting, so its control is named, described and marked required or invalid the way assistive technologies expect.

Now let's look at how a field behaves. Most of what it does can't be seen at all: it's the wiring between the control and its label, hint and error, which decides what screen readers announce, what voice control users can say and what happens when a form can't be sent.

Accessible

Label every control

The label is the control's accessible name. Screen readers read it when the control gets focus, voice control users say it out loud ("click Email address"), and tests and agents use it to find the control. A click on it focuses a text control, or toggles a switch, which makes for a much bigger target. Here's how the Field labels each kind of control:

  • Every text control and switch in a Field has a visible label, which the Field renders as a <label> and links to the control with for and its id (WCAG 1.3.1, 3.3.2, 4.1.2). A <label> with for names any labelable element, like an <input>, a <textarea> or a <select> (HTML Standard).
  • A Checkbox's own <label> wraps its box and its text, so it names the checkbox already. The Field adds no second label, which would give the checkbox a name made of both, read twice.
  • A Checkbox Group and a Radio Group are named by their <legend> (see Name groups by their legend), so the Field adds no label to either.
  • Write labels that say what to fill in, like "Email address" rather than "Email" or "Contact" (WCAG 2.4.6). If you name a control with aria-label instead, make sure the name includes any visible text, ideally at the start (WCAG 2.5.3).

Where the label goes depends on the control. It sits above a text control, before a switch on the same row and after a checkbox's box, which you'll find under Controls, along with the reasons for each.

Describe it with the hint and the error

The Field points the control's aria-describedby at the hint, then at the error, then at anything the control describes itself with, like a Textarea's count. The control's description is those elements' text, joined in that order (Accessible Name and Description Computation), and screen readers like NVDA, JAWS and VoiceOver read it after the control's name, role and states. So people hear what the field asks for first, then what's wrong with it, right before they act on it. A few things keep that description useful:

  • The error element is only in the page while there's an error, so screen readers never read an empty one, and the description never points at an id that isn't there.
  • A description is a flat string, so keep the hint and the error to plain sentences. Links, lists and buttons in them reach screen readers as text, if at all.
  • An aria-describedby you set on the control yourself comes after the Field's, so you can add a note of your own, like a link to your privacy policy, without losing the hint or the error.

ARIA also has aria-errormessage, which only points at an error, and only while the control is invalid (WAI-ARIA 1.2). The spec suggests it for errors, but screen readers don't agree on it yet. In Adrian Roselli's tests, NVDA, Narrator and VoiceOver on macOS didn't read it when people moved to the field, while NVDA, JAWS, Narrator and VoiceOver all read aria-describedby (Exposing Field Errors). That's why we pick aria-describedby, which they all read, over the attribute made for the job.

Name groups by their legend

A group of checkboxes or radios asks one question, like "How can we contact you?", and each option answers it. The <legend> asks the question, and it names the <fieldset> around the options (HTML-AAM), so screen readers read it as people enter the group, before the first option's own name (WCAG 1.3.1). So the Field leaves the naming to the group:

  • The Field never renders a label for a group, and warns you in development if you give it one, since a <label> can't name a <fieldset>, and a second name would only repeat the first.
  • Around a Checkbox Group, the hint and the error describe its <fieldset>, so screen readers read them as people enter the group. The group marks each of its options invalid too, since ARIA doesn't support aria-invalid on the group role (WAI-ARIA 1.2).
  • Around a Radio Group, the group renders the hint and the error, and links them, the way it always does. It takes the radiogroup role, which does support aria-invalid.

Contrast and size

The label, the hint and the error need a contrast of at least 4.5:1 against the page (WCAG 1.4.3), which you'll find worked out under Tokens. The label is part of its control's hit area, so a click anywhere on it focuses or toggles the control, which helps people who find small targets hard to hit.

Predictable

Give every control a stable id

The label's for and the control's aria-describedby point at IDs, so the control needs an id that's unique in the page (HTML Standard) and the same every time the page renders, on the server and in the browser. The Field takes it from what you pass: its own id, or else the control's id, or else the control's name. The hint's is that id followed by -hint, and the error's by -error, as the Radio Group builds its own from its name.

We recommend against generated IDs. Your framework's ID helper makes up an ID from where the component sits in the tree, so it changes when a component is added above it, and it only matches between the server and the browser when both render exactly the same tree (as one such helper's documentation warns). The Tooltip uses one for its own id, and the Popover for the name of its anchor, each set once it mounts, since neither needs it before a script runs. A label has to work in the server's markup, before any script runs, so the Field builds its IDs from what you pass, and the markup stays the same everywhere.

With no id and no name, the Field warns you in development, since nothing would link the label to the control. Keep in mind that two forms on one page with the same field names, like two sign-in forms, need an id on each Field, so their IDs don't clash.

Mark what's required in words

Mark whichever there are fewer of, the required fields or the optional ones, and do it in words. Most forms only ask for what they need, so nearly every field is required, and it's clearer to add "(optional)" to the label of the few that aren't, as the GOV.UK Design System recommends, than to put an asterisk on all the rest (GOV.UK: Question pages). An asterisk is easy to miss, and means nothing until people find the note that explains it. Here's how the Field and the controls handle it:

  • The Field adds no marker of its own, since which fields get one is a decision about the whole form. Put "(optional)" in the label you pass, so it's part of the control's name too.
  • required sets the control's native required, which screen readers announce as "required" along with its name (HTML-AAM), and which keeps the browser's own checks working, if you use them.
  • A Checkbox Group never gets required, even from a required Field, since HTML has no way to require one of a group of checkboxes, and required on each one would require them all. Say it in the hint, like "Pick at least one", and check it when the form is sent (GOV.UK: Checkboxes).
  • A Radio Group gets required on each of its radios, which HTML reads as one choice that has to be made.

Check values when people leave or submit

An error that shows up after the first letter only tells people what they already know, so check a value when they leave the control, or when they submit the form. Once an error shows, check again as they type, and clear it as soon as the value is right. These are the Input's rules, and every Field follows them.

We'll send a link to confirm it

import { useRef, useState } from "react";
import { flushSync } from "react-dom";

export function SignUp() {
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [status, setStatus] = useState("");
  const form = useRef<HTMLFormElement>(null);

  function submit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const found = check(new FormData(event.currentTarget));
    // Render the errors before moving focus, so screen readers read the first one with its field.
    flushSync(() => setErrors(found));
    const first = Object.keys(found)[0];
    if (!first) return setStatus("Your account is ready");
    setStatus("Couldn't create your account. Fix the errors to continue.");
    form.current?.querySelector<HTMLElement>(`#${first}`)?.focus();
  }

  return (
    <form ref={form} noValidate onSubmit={submit}>
      <Field id="name" label="Full name" error={errors.name} required>
        <Input name="name" autoComplete="name" />
      </Field>
      <Field id="email" label="Email address" error={errors.email} required>
        <Input name="email" type="email" autoComplete="email" />
      </Field>
      <Field id="company" label="Company (optional)">
        <Input name="company" autoComplete="organization" />
      </Field>
      <Button type="submit">Create account</Button>
      <p role="status">{status}</p>
    </form>
  );
}

check() stands in for your own checks, which return an error message for each field that needs one. Try submitting the form above without filling it in: focus moves to "Full name", and a status message says what happened. Here's what it does, and why:

  • When a submit fails, move focus to the first invalid control, so keyboard and screen reader users land right on it and hear its error with its name. Do it once the errors are in the page, which in most frameworks means waiting for the next render.
  • Say what happened in a status message, with role="status", which screen readers announce without moving focus (WCAG 4.1.3). Moving focus to a control that already has it, as when people press Enter in it, announces nothing at all. With several errors, a summary at the top of the form, with a link to each control, works even better (GOV.UK: Error summary).
  • The error message itself isn't a live region. A message that's announced as people leave a control talks over the name of the next one they land on, and in Adrian Roselli's tests, VoiceOver and Narrator didn't announce it at all (Exposing Field Errors). People hear it with the control when they come back to it, and the status message tells them to.
  • Say what's wrong and how to fix it, as in "Enter an email address in the correct format, like name@example.com", rather than "Invalid input" (WCAG 3.3.1, 3.3.3).

The sources don't agree on where the error goes. GOV.UK puts it between the hint and the control, so people read it before they reach the value (GOV.UK: Error message). We put it after a single control, as the Input, the Checkbox and the Switch pages already do, and as Material Design puts a text field's supporting text (Material Design 3: Text fields), so showing it never moves the control people are looking at, and it sits next to the value it's about. Screen readers read it in the same place either way, in the control's description after the hint.

Use the browser's checks, not its messages

The browser can check a value for you, from the attributes that describe it, like required and type="email", and it tells you what's wrong through the control's validity (HTML Standard). Its own messages are another matter. They're written in the browser's language rather than your page's, say what's wrong without saying how to fix it, like "Please fill out this field", and show in a bubble that looks different in every browser and vanishes after a few seconds.

We recommend keeping the attributes and leaving out the bubbles. Put novalidate on the form, which turns off the bubbles but keeps validity up to date, and turn what it reports into your own messages:

// An error message for a control, from what the browser's checks found, or an empty string.
function messageFor(control: HTMLInputElement, label: string): string {
  const { validity } = control;
  if (validity.valueMissing) return `Enter your ${label.toLowerCase()}`;
  if (validity.typeMismatch && control.type === "email") {
    return "Enter an email address in the correct format, like name@example.com";
  }
  return "";
}

required stays on the control either way, so screen readers still announce it. The Field shows whatever error you pass it, whether it comes from the browser's checks, your own or your server's, since only your code knows what makes a value right.

Every state

Disabled

disabled goes to the control's own disabled, so people can't focus or change it, and the browser's checks skip it (HTML Standard). Its form leaves it out when it's sent (HTML Standard), and the label dims along with it. Keep in mind that a disabled control can't say why it's disabled, so it's often better to leave it enabled and explain the problem in an error when people try to use it, or to leave the field out until it applies.

Invalid

While error is set, the Field sets aria-invalid="true" on the control, renders the error message and adds it to the control's description, all in the same render. Screen readers then announce the control as invalid along with its name, and read the error after the hint. When you clear error, all three go away together, so the Field never leaves a control invalid without a message saying why. An aria-invalid you set on the control yourself stays, as does a Textarea's over its limit, since the Field only sets what it adds.

There's no separate invalid prop. An invalid state without a message can't tell people what to fix (WCAG 3.3.1), so the error message is what makes a control invalid. A Textarea over its limit stays invalid whatever the Field says, since its count says what's wrong, as does an aria-invalid you set on the control yourself.

Required

required sets the control's native required, and nothing else changes in how it looks. A required control isn't invalid until people try to send the form without it, which is why the Field never sets aria-invalid from required alone, and why the controls never style :invalid (WAI-ARIA 1.2).

Adaptive

Pointer, touch and pen

The label is part of the control's hit area, so on a phone, a tap on "Price alerts" flips the switch, and a tap on "Email address" opens the keyboard. That's the browser's own behaviour for a <label>, which works before any script loads.

Forced colours

In forced colours mode, the browser replaces every colour with the system's, which would leave a disabled label at half the strength of the system's text. The Field draws a disabled label in GrayText instead, as the controls do with their own disabled text. The error keeps its text, which says what's wrong without its colour.

Text size and spacing

The label, the hint, the error and the space between them are all set in rem, with a line height of 1.25, so they grow with the text when people make it larger, and wrap rather than clip on a narrow screen (WCAG 1.4.4, 1.4.12).

Languages and direction

The Field is laid out with logical properties, so in right-to-left layouts like Arabic or Hebrew, a Switch stays at the end of its row, which is then on the left, and a Checkbox's hint and error are set in from the right. The label, the hint and the error are your own text, so translate them along with the rest of the page. They're plain strings, so wrap a part that always runs left to right, like an example email address, in Unicode's isolate characters (U+2066 to U+2069), which keep it in order inside a right-to-left sentence (W3C Internationalization).

UX Checklist

RuleLevelScopeRequirementBasisCheck
field/visible-labelMustUsageEvery text control and switch in a Field has a visible label that says what to fill inWCAG 1.3.1, 3.3.2, 4.1.2 (A), 2.4.6 (AA)axe label, review
field/label-forMustComponentThe label's for is the control's id, so a click on it focuses or toggles the controlWCAG 1.3.1 (A)Unit test
field/one-nameMustComponentA Checkbox, a Checkbox Group and a Radio Group get no label from the Field, so each keeps its own nameWCAG 4.1.2 (A)Unit test
field/group-legendMustUsageA Checkbox Group and a Radio Group are named by a legend that asks the question their options answerWCAG 1.3.1 (A)Review
field/describedMustComponentThe control's aria-describedby lists the hint, then the error, then the control's ownWCAG 1.3.1 (A)Unit test
field/no-empty-errorShouldComponentThe error element is only in the page while there's an errorOpen ComponentsUnit test
field/error-messageMustUsageAn error says what's wrong and how to fix it, in textWCAG 3.3.1 (A), 3.3.3 (AA)Screen reader
field/invalid-stateMustComponentThe Field sets aria-invalid="true" on the control while it has an error, and never sets it otherwiseWCAG 4.1.2 (A)Unit test
field/not-liveShouldComponentThe error message isn't a live region, so it never talks over the control people move toOpen ComponentsUnit test
field/stable-idMustComponentThe control's id comes from the Field's id, the control's id or its name, and the hint's and error's from it, with nothing generatedOpen ComponentsUnit test
field/required-nativeMustComponentrequired sets the control's native required, or each radio's in a Radio Group, and never a Checkbox Group'sHTMLUnit test
field/required-in-wordsShouldUsageRequired or optional fields are marked in words, on whichever there are fewer of, like "(optional)" in the labelOpen Components, beyond WCAG 3.3.2 (A)Review
field/validate-lateShouldUsageErrors show when people leave a control or submit, never while they're first typing, and clear as soon as the value is fixedOpen ComponentsReview
field/focus-first-errorShouldUsageWhen a submit fails, focus moves to the first invalid control, or to a summary of the errors, once the errors are in the pageOpen ComponentsKeyboard, screen reader
field/status-messageShouldUsageA failed submit says what happened in a status message that screen readers announceWCAG 4.1.3 (AA)Screen reader
field/novalidateShouldUsageThe form has novalidate, while its controls keep required and type, and the browser's messages are never shown as they areOpen ComponentsReview
field/disabledMustComponentdisabled sets the control's own disabled, so it can't be focused, changed or sentHTMLUnit test
field/mirrors-in-rtlShouldComponentIt uses logical properties, so it mirrors in right-to-left layoutsOpen ComponentsReview

FAQ

Field or something else?

A Field labels, describes and validates one control. For anything around it, another component, or plain markup, will do a better job:

When you need to…Use
Label a text control, a checkbox or a switch, with a hint and an errorField
Ask one question with several checkboxes or radiosCheckbox Group or Radio Group, in a Field for its hint and error
Ask one question with several text controls, like a date in three partsA <fieldset> with a <legend>, and a Field for each part
Group a few related questions, like an addressA <fieldset> with a <legend>, around their Fields
Name a search input next to its Search buttonAn aria-label on the Input, without a Field
List every error in a form at onceAn error summary at the top of the form, with a link to each control
Copyright © 2026