User Interface (UI)
Let's start with how a field looks: its parts, where they go around each kind of control, its sizes, how it shows each state and the tokens it reads.
Anatomy
- Container — The root
<div>, which lays out the parts and carries the size. A Radio Group gets none, since it lays out its own. - Label — The
<label>, linked to the control withfor, which names it. A Checkbox and the groups name themselves, so they don't get one. - Hint — Optional. Help people need before they fill the control in, like the format you expect, which describes it.
- Control — The one control in the Field, which keeps its own parts, and gets the Field's
id, description and states. - Error — Only there while the control has an error. It says what's wrong and how to fix it, and describes the control too.
Every field has a container and a control. The label, hint and error are the Field's own parts, and their order depends on the control, which you'll find in the DOM contract.
Controls
What the Field renders depends on the control inside it, since each kind of control is named and laid out in its own way. The props stay the same for all of them:
| Control | Label | Hint and error |
|---|---|---|
Input, Textarea or a native <input>, <textarea> or <select> | The Field's, above the control | The hint between the label and the control, the error after it |
| Checkbox | Its own, after its box | After it, lined up with its label |
| Switch | The Field's, before it on the same row | Under the row |
| Checkbox Group | Its own legend | After the group |
| Radio Group | Its own legend | Its own, above its options, from the Field's props |
Text controls
We'll only use it to send your receipt
Enter your email address
<Field
id="email"
label="Email address"
hint="We'll only use it to send your receipt"
error="Enter your email address"
required
>
<Input type="email" autoComplete="email" />
</Field>
We recommend putting the label above a text control, with the hint between them, as the Input does. People read both before they type, and they stay in view when the page is zoomed in or the screen is narrow, where a label beside its control gets pushed off to the side. The error goes after the control, right next to the value it's about.
A sentence or two about you, for your profile
<Field id="bio" label="Bio" hint="A sentence or two about you, for your profile">
<Textarea limit={200} />
</Field>
A Textarea with a limit shows its count right under the box, before the Field's error, and adds it to its description after the Field's hint and error, so people hear what the field asks for before how many characters they have left.
The Field takes a native <input>, <textarea> or <select> as a text control too. A native checkbox or radio isn't one, since a label above it would read as a heading rather than its name, so use the Checkbox or the Radio Group, which name themselves. The Field warns you in development when it gets one.
Checkboxes
You can read them before you sign up
Agree to the terms to create an account
<Field
id="terms"
hint="You can read them before you sign up"
error="Agree to the terms to create an account"
required
>
<Checkbox name="terms">I agree to the terms</Checkbox>
</Field>
A Checkbox's label is part of it, after its box, so a click on the text toggles it. That's where people expect it, and where the WAI forms tutorial puts it, so the Field doesn't render a second label, which would leave the checkbox with two. Its hint and error go after it, set in by the width of the box and its gap (--field--indent), so they line up with its label and read as part of the same option.
Switches
We'll email you when the price drops
<Field id="alerts" label="Price alerts" hint="We'll email you when the price drops">
<Switch />
</Field>
A switch goes after its label, on the same row, with the hint and the error under both. It's how settings lists are laid out, with the labels down one side and the switches down the other, and Material Design pairs every switch with a label on its row for the same reason (Material Design 3). The row uses logical properties, so the switch moves to the left in right-to-left layouts.
Groups
Pick at least one
Choose how we can contact you
<Field id="contact" hint="Pick at least one" error="Choose how we can contact you">
<CheckboxGroup name="contact" options={contact}>How can we contact you?</CheckboxGroup>
</Field>
A group of checkboxes or radios asks one question, and its <legend> asks it, so the legend names the group the way a label names a single control. The Field doesn't render a label for either group, and warns you in development if you give it one. Around a Checkbox Group, it renders the hint and the error after the group and links them to its <fieldset>, which passes the invalid state on to every option.
<Field
hint="Express arrives the next working day"
error="Choose a delivery option"
required
>
<RadioGroup name="delivery" legend="Delivery">
<Radio value="standard">Standard</Radio>
<Radio value="express">Express</Radio>
</RadioGroup>
</Field>
A Radio Group already renders its own legend, hint and error, with the error above its options, so people read what's wrong before they choose again. Around one, the Field renders nothing of its own. It hands the group its hint, error, required and disabled, and the group puts them where it always does. If the group has a hint or an error of its own as well, the Field's wins, so it's best to set each one in only one place.
Either way, the Field's hint and error look the same as the Radio Group's, with the same text size, colour and spacing, so a form can mix single controls and groups without them looking out of place.
Sizes
The size prop sets the text size of the label, hint and error, and goes to the control too, so the two always match. A native control keeps its own size, which counts characters rather than naming a size. Without one, the Field takes the control's own size, and md is the default. The text sizes and gap are the Radio Group's, which are in turn the Input's.
As it's written on your card
As it's written on your card
As it's written on your card
As it's written on your card
As it's written on your card
<Field id="name-xs" size="xs" label="Full name" hint="As it's written on your card">
<Input autoComplete="name" />
</Field>
{/* …sm, md, lg, xl */}
| Size | Text | Gap | Checkbox indent |
|---|---|---|---|
xs | 12px | 4px | 20px |
sm | 14px | 4px | 24px |
md | 14px | 4px | 26px |
lg | 16px | 4px | 28px |
xl | 16px | 4px | 34px |
The checkbox indent is the Checkbox's box plus its gap at the same size, which is also the Checkbox Group's indent under a select-all box. All of them are set in rem, with a line height of 1.25, so they grow with the text when people make it larger.
States
The Field has no look of its own for each state. It sets the state on the control, which already has one, so what people see and what assistive technologies report come from the same attribute.
You can't change it
Enter an email address in the correct format, like name@example.com
<Field id="name" label="Full name" required>
<Input autoComplete="name" />
</Field>
<Field id="company" label="Company (optional)">
<Input autoComplete="organization" />
</Field>
<Field id="username" label="Username" hint="You can't change it" disabled>
<Input defaultValue="ada" />
</Field>
<Field
id="email"
label="Email address"
error="Enter an email address in the correct format, like name@example.com"
>
<Input type="email" autoComplete="email" defaultValue="ada@" />
</Field>
| State | Set on the control | Recommended look |
|---|---|---|
| Required | The native required | No marker of its own, with "(optional)" on the fields that aren't |
| Disabled | The control's own disabled | The control's own, and the label at 50% opacity |
| Invalid | aria-invalid="true", with the error in its description | The control's own, and the error in --color--error-text |
The label dims when the control itself is disabled, which the Field reads from the control's own :disabled rather than from a prop, so the label can't look disabled while the control isn't. A disabled button in an Input's actions doesn't count.
Required has no look at all, since which fields to mark is a decision about the whole form, as you'll find under Mark what's required. The error message is text rather than a colour alone, so it means the same to people who can't tell red apart (WCAG 1.4.1).
None of the states move the control. The error goes after a single control, so showing it never pushes the control, or anything above it, out of the way while people are looking at it.
Tokens
The field reads two theme tokens, which every component shares:
| Token | Used for |
|---|---|
--color--neutral-text | The label, and mixed into the hint at 70% |
--color--error-text | The error message |
The hint is the text colour at 70%, the same mix as the Input's placeholder and the Radio Group's hint, which needs 4.5:1 against the page (WCAG 1.4.3). With our reference tokens, from the Tailwind CSS palette, it comes to 5.6:1 on white and 7.4:1 on zinc-900. --color--error-text passes 4.5:1 on both too.
If you change the tokens, make sure to check the same pairs. You'll find the tokens themselves in the Button's tokens.css, which you can copy into your theme.
If you need to tune a field, you can set its own variables, such as --field--gap or --field--indent, from a class. The Field's styles sit in the components cascade layer, so your class wins without any specificity tricks:
import styles from "./SignUp.module.css";
export function SignUp() {
return (
<Field id="email" label="Email address" className={styles.roomy}>
<Input type="email" autoComplete="email" />
</Field>
);
}
.roomy {
--field--gap: 0.5rem;
}
| Variable | What it sets | md |
|---|---|---|
--field--gap | The space between the label, the hint, the control and the error | 0.25rem |
--field--font-size | The text size of the label, the hint and the error | 0.875rem |
--field--column-gap | The space between a Switch and its label | 1rem |
--field--indent | How far a Checkbox's hint and error sit in | 1.625rem |
UI Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
field/label-position | Should | Component | The label sits above a text control, with the hint between them, before a Switch on the same row, and after a Checkbox's box | Open Components | Unit test |
field/error-after | Should | Component | The error comes after a single control, so showing it never moves the control | Open Components | Unit test |
field/text-contrast | Must | Component | The label, the hint and the error have a contrast of 4.5:1 against the page | WCAG 1.4.3 (AA) | axe color-contrast, contrast checker |
field/error-in-text | Must | Both | An error is a message in text, never a colour alone | WCAG 1.4.1 (A) | Review |
field/like-groups | Should | Component | The hint and the error have the same text size, colour and spacing as the Radio Group's | Open Components | Review |
field/checkbox-indent | Should | Component | A Checkbox's hint and error line up with its label, rather than its box | Open Components | Visual regression test |
field/resizable | Must | Component | Its text and spacing are set in rem, so they grow with the text size rather than clip | Open Components, beyond WCAG 1.4.4, 1.4.12 (AA) | 200% zoom |
field/forced-colors | Must | Component | A disabled label stays visible in forced colours mode | Open Components | Emulation |
Overview
How to build a field that labels, describes and explains the errors of any form control, works for everyone, feels familiar to every developer and can be checked by your agents.
User Experience
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.