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.

Molecule8 grey pages20 fields2 axes1 state of its own

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

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 form when submitting the field IS the action, which is only the catalogue search. A div everywhere else, because the four fields of Add a Subscription are one form together and not four

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

One line under the field, always the same line

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.

Never a message that appears from nowhere

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.

Use a form field

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.

Use a switch row instead

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.

Restlight
--text-muted on the label and the hint
Restdark
the same role, the other half of the pair. 6.4:1 against the dark surface
Invalidlight
--text-failure, 4.6:1 on its own wash, the tightest pair in the system
Invaliddark
--text-failure at 7.3:1. Clay and never red: the persona closes any app that shows her red, and that is a recorded quote
Retake, 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

ReadsWhich tokenGrowing 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-4primitive: a spacing has nothing to override
the gap to the next field--space-16primitive
the label and hint size--type-metaprimitive, 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.