Form field
The unit a person fills in: the label that names it, the control they type into, and the hint underneath. It draws almost nothing of its own, and the one rule that matters most in it is a colour it hands to the hint when the control goes invalid.
Anatomy
Three slots around one atom, and that is what makes it a molecule rather than a wrapper. Everything below is drawn by design/system/components/form-field.css and the atom files it hosts, through the same entry point a product screen loads, so this page cannot drift from the screens.
For example, Aug 3, 2026
.fieldthe molecule: a block with a bottom margin and nothing else. No fill, no edge, no padding of its own. Rendered as adivon 17 fields and as aformon 3labelthe name of the field, block-level and muted, bound to the control byfor. It is a real target: clicking it focuses the control, and the control is what lights up.input/.selectthe atom inside, drawn entirely by text-input.css or select.css, and both wear the class step 6 gave them. Its border, fill, ink and four states are its own. This molecule does not touch them.hintthe optional line under the control, same size and same ink as the label. 8 of the 20 fields carry one. This is the slot that turns clay when the control is invalid
Variants and sizes
Two axes, and neither of them changes a value: one is which element hosts the field, the other is whether a hint is present. There is no size axis, because a form field has no size of its own. Its height is the control's 44px floor plus a 12px label and, sometimes, a 12px hint.
| Host A |
Difference | Where it stands |
|---|---|---|
div <div class="field"> |
none | 17 fields on 7 pages: Add a Subscription and its empty and error states, Connect your bank, Settings, Settings with no account, Sign in |
form <form class="field" role="search"> |
none | 3 fields, all of them the preset search on Add a Subscription and its empty and loading states |
| Content A hint answers a question the label cannot answer in two words. If it repeats the label it is deleted; if it is the only way to know what to type, it is not optional |
Values | Where it stands |
|---|---|---|
| Without a hint | 12px label 4px gap 44px control |
12 of the 20. The default: a field whose label is enough |
With a hint .hint |
+ 4px + 12px hint |
8 fields on 6 pages. The longest is the email hint on Connect your bank, which explains why an email is being asked for at all |
For example, Aug 3, 2026 | ||
| Inline error text | does not exist | Forbidden with a reason rather than missing. When the control is invalid the HINT turns clay and its sentence becomes the message: one line under the field, in one place, whether it is explaining or correcting. A second line that appears only on failure makes the field jump downward at the moment a person is already annoyed |
The search field is not a search component. It is this field with a form around it and type="search" on the input, and it looks identical because it is identical. A separate search component would be a second field that drifts.
When to use it
A person meets this component at the two places where the product asks them for something instead of showing them something: adding a subscription by hand, which is the equal second path D2 protects, and giving an email so their list has somewhere to live. Both are moments where an anxious person is most likely to leave, so the field is deliberately plain. There is no asterisk, no "required", no character counter and no live validation as you type.
Labels and hints come from voice/docs/microcopy.md and are the person's own words: "Name", "Amount", "Billing frequency", "Next payment date", "For example, Aug 3, 2026". The hint on Connect your bank is the longest string in the component and it earns its length, because it answers the question the field provokes: "Bank data needs somewhere that is yours: a place you can sign back into, and a place you can tell us to delete."
The rule, and the anti-rule
Enter an amount, like $9.99
The hint is the error message. Same element, same position, same size: it changes colour and it changes its sentence, and nothing moves. The field is exactly as tall while it is wrong as it was while it was right, which is the difference between a correction and a shove.
Enter an amount, like $9.99
A message inserted below a field on failure makes the field taller than it was a moment ago and moves everything under it down, at exactly the moment a person is already annoyed. On a form of four fields the layout shifts under their hand. If a field can be wrong, it carries its hint before it is wrong, and the hint changes colour rather than the form changing shape.
A value the person supplies, whether they type it or pick it. The label names it, the control takes it, and the field is what holds the two together and spaces them from the next one.
A preference that is on or off is not a field. It is the switch row, where the control sits to the left of the sentence rather than under a label, and where changing it takes effect at once instead of waiting for a Save. Putting a checkbox inside a form field would tell a person their choice has not been recorded yet. The switch row is a molecule of its own, so what stands here is its real markup drawn by the checkbox atom alone.
States
One state, and it is not a pointer state. The field itself is never hovered, never focused and never pressed: it has no fill, no edge and no target. What it has is validity, and the field hears about it from the control inside through :has([aria-invalid="true"]). The hint turns clay and nothing else moves. The four pointer states of the control are the control's own and are on text-input.html and select.html, where a person looking for them will go anyway.
The hook is the native attribute and not a class, and that is not a preference. A sweep of all 57 grey pages returns no aria-invalid, no required and no error class: the invalid form is authored rather than extracted. A class would have to be added to every screen before the state could ever fire, and it would say nothing to a screen reader. The attribute is the one a form already sets, and it announces the field as invalid at the same moment it repaints it.
Both states, live
Enter an amount, like $9.99
Enter an amount, like $9.99
The second one is genuinely invalid, by the attribute rather than by a class the product does not carry. Tab into either input to see the ring, and note that the ring on the invalid one is clay rather than petrol: that is select.css and text-input.css reading --line-failure, not this file.
The two, in both themes
The states are described, not photographed. Founder's decision, 2026-08-11: the snapshot strip went from every page in this kit, 120 images across 18 pages. They were framed as measurements rather than illustrations, and that framing was honest, but the bill came due all session: a value moves, and every picture of it is quietly stale until somebody re-shoots it. A stale picture is worse than no picture, because it looks checked. What is left is what a picture never carried anyway: the state, and the token it reads. The resting component is live and interactive above, so hover it, tab to it, and read the value off the real thing rather than off a photograph of it.
--text-muted on the label and the hint--text-failure, 4.6:1 on its own wash, the tightest pair in the system--text-failure at 7.3:1. Clay and never red: the persona closes any app that shows her red, and that is a recorded quoteRetake, and this is the whole recipe, because there is no node Playwright in this repo:
serve the repo, then for theme in (none, data-theme="dark" on <html>):
goto /design/kit/form-field.html
screenshot the FIRST .field in #live-rest -> screens/form-field-rest-<theme>.png
screenshot the SECOND .field in #live-rest -> screens/form-field-invalid-<theme>.png
Both are reached the way the product reaches them: a real aria-invalid attribute on a
real control, no class the screens do not carry.
The technical half
| Reads | Which token | Growing from |
|---|---|---|
| the label and the hint | --text-muted | --muted, the single secondary tier |
| the hint when the control is invalid | --text-failure | --clay. Ink, so it answers to 4.5:1 and not to the 3:1 the invalid border answers to |
| the gap under the label, and above the hint | --space-4 | primitive: a spacing has nothing to override |
| the gap to the next field | --space-16 | primitive |
| the label and hint size | --type-meta | primitive, the 12px step |
Lives in design/system/components/form-field.css. It reads no fill, no edge and no radius at all, which is the shortest token list of any molecule in the system: everything a person sees inside it belongs to the control. Coloured screens carrying it: Add a Subscription, its empty state, its error state, its loading state, Settings, Settings, no account.
<div class="field">
<label for="sub-next">Next payment date</label>
<input id="sub-next" type="text" value="Aug 3, 2026">
<p class="hint">For example, Aug 3, 2026</p>
</div>
<form class="field" role="search" onsubmit="return false">
<label for="preset-search">Find a service</label>
<input id="preset-search" type="search" placeholder="Search 400+ services">
</form>
The hint took the reading measure on 2026-08-17. Six of the twenty fields carry a hint that is a fragment and one carries a paragraph: the email hint on the coloured Connect Bank, which the stage 12 sweep read at 77.6 characters to a line at a 640 window, on the screen where a person is deciding whether to trust us. max-width: var(--container-text), which at --type-meta is 394px rather than the 459 the same token gives body text: ch follows the font, which is the whole reason the measure is not written in pixels. A fragment never reaches the cap, so the other six are untouched.