User Experience (UX)
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 withforand itsid(WCAG 1.3.1, 3.3.2, 4.1.2). A<label>withfornames 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-labelinstead, 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
idthat 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-describedbyyou 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 supportaria-invalidon thegrouprole (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
radiogrouprole, which does supportaria-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.
requiredsets the control's nativerequired, 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, andrequiredon 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
requiredon 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.
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 pressEnterin 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
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
field/visible-label | Must | Usage | Every text control and switch in a Field has a visible label that says what to fill in | WCAG 1.3.1, 3.3.2, 4.1.2 (A), 2.4.6 (AA) | axe label, review |
field/label-for | Must | Component | The label's for is the control's id, so a click on it focuses or toggles the control | WCAG 1.3.1 (A) | Unit test |
field/one-name | Must | Component | A Checkbox, a Checkbox Group and a Radio Group get no label from the Field, so each keeps its own name | WCAG 4.1.2 (A) | Unit test |
field/group-legend | Must | Usage | A Checkbox Group and a Radio Group are named by a legend that asks the question their options answer | WCAG 1.3.1 (A) | Review |
field/described | Must | Component | The control's aria-describedby lists the hint, then the error, then the control's own | WCAG 1.3.1 (A) | Unit test |
field/no-empty-error | Should | Component | The error element is only in the page while there's an error | Open Components | Unit test |
field/error-message | Must | Usage | An error says what's wrong and how to fix it, in text | WCAG 3.3.1 (A), 3.3.3 (AA) | Screen reader |
field/invalid-state | Must | Component | The Field sets aria-invalid="true" on the control while it has an error, and never sets it otherwise | WCAG 4.1.2 (A) | Unit test |
field/not-live | Should | Component | The error message isn't a live region, so it never talks over the control people move to | Open Components | Unit test |
field/stable-id | Must | Component | The 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 generated | Open Components | Unit test |
field/required-native | Must | Component | required sets the control's native required, or each radio's in a Radio Group, and never a Checkbox Group's | HTML | Unit test |
field/required-in-words | Should | Usage | Required or optional fields are marked in words, on whichever there are fewer of, like "(optional)" in the label | Open Components, beyond WCAG 3.3.2 (A) | Review |
field/validate-late | Should | Usage | Errors show when people leave a control or submit, never while they're first typing, and clear as soon as the value is fixed | Open Components | Review |
field/focus-first-error | Should | Usage | When a submit fails, focus moves to the first invalid control, or to a summary of the errors, once the errors are in the page | Open Components | Keyboard, screen reader |
field/status-message | Should | Usage | A failed submit says what happened in a status message that screen readers announce | WCAG 4.1.3 (AA) | Screen reader |
field/novalidate | Should | Usage | The form has novalidate, while its controls keep required and type, and the browser's messages are never shown as they are | Open Components | Review |
field/disabled | Must | Component | disabled sets the control's own disabled, so it can't be focused, changed or sent | HTML | Unit test |
field/mirrors-in-rtl | Should | Component | It uses logical properties, so it mirrors in right-to-left layouts | Open Components | Review |
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 error | Field |
| Ask one question with several checkboxes or radios | Checkbox Group or Radio Group, in a Field for its hint and error |
| Ask one question with several text controls, like a date in three parts | A <fieldset> with a <legend>, and a Field for each part |
| Group a few related questions, like an address | A <fieldset> with a <legend>, around their Fields |
| Name a search input next to its Search button | An aria-label on the Input, without a Field |
| List every error in a form at once | An error summary at the top of the form, with a link to each control |
required on the rest, which screen readers announce. The Field doesn't add a marker of its own, since only you can see the whole form.