PATTERN NAME: Form Field
COMPOSES: .form-label (+ -required / -optional) | .form-control / .form-select / .form-check | .form-text | .invalid-feedback | .valid-feedback
One wrapper that groups a field's label, control, helper text and feedback, and carries the
validation state once — .form-field.is-invalid tints the control
and reveals the error, instead of the caller marking every part by hand. Adds
.form-field-errors for the several-messages-for-one-field case the design system
previously had no shape for.
Default: label + control, no state. A visible <label for> is
always required — never a placeholder standing in for a label.
<div class="form-field"> <label class="form-label form-label-required" for="ff-name">Child's full name</label> <input type="text" class="form-control" id="ff-name" aria-required="true"></div>
Helper text: .form-text below the control, referenced by the
control's aria-describedby. Helper text explains how to answer; it is not an error.
Use the date on your child's birth certificate.
<input class="form-control" id="ff-dob" aria-describedby="ff-dob-help"><p class="form-text" id="ff-dob-help">Use the date on your child's birth certificate.</p>
Invalid: .is-invalid goes on the .form-field wrapper.
It tints the control's border and reveals the .invalid-feedback line — which stays
hidden inside a wrapper that is not marked invalid, so the error text can be server-rendered up
front. aria-invalid="true" and aria-describedby stay on the control:
those are the accessible name/description contract, not styling, and both the helper text and
the error must be referenced when both are present.
We'll send your application updates here.
Enter an email address in the format name@example.com.
<div class="form-field is-invalid"> <input class="form-control" id="ff-email" aria-invalid="true" aria-describedby="ff-email-help ff-email-error"> <p class="form-text" id="ff-email-help">…</p> <p class="invalid-feedback" id="ff-email-error">…</p></div>
Multi-error list: .form-field-errors is a real
<ul> whose id goes in the control's
aria-describedby, so a screen reader hears every message, not just the first. Ink
and type match .invalid-feedback exactly — one error and three errors must not look
like different components.
You'll use this to check your application later.
<ul class="form-field-errors" id="ff-password-errors"> <li>Use at least 8 characters.</li></ul>
Valid: .is-valid on the wrapper tints the control green and
reveals .valid-feedback. Use it sparingly — confirm a field the user could
reasonably doubt (a postcode that resolved, a reference that was found), not every field they
filled in correctly.
Matched: 12 Example Street, London.
<div class="form-field is-valid"> … <p class="valid-feedback" id="…">…</p>
Disabled: the wrapper adds nothing — disabled is an attribute on
the control and .form-control:disabled already styles it. Say why in the
helper text; a control that is dimmed and silent is a dead end for a first-time applicant.
Available once you've chosen a first preference.
<select class="form-select" id="ff-school" disabled aria-describedby="ff-school-help">
Beyond text inputs: the wrapper also carries state for
.form-check controls — the ring around a required consent checkbox is the most
common invalid field these users meet. For a group of choices, put .form-field on a
<fieldset> and the .form-label on its <legend>,
so one error covers the whole group.
Confirm the information is correct before submitting.
<fieldset class="form-field is-invalid"><legend class="form-label">…</legend>
Why this matters: .invalid-feedback on its own is visible the
moment it is in the DOM — components/form.css gives it an unconditional
display: block, and the .is-invalid ~ .invalid-feedback rule that
looked like a gate was a no-op. Inside .form-field it is genuinely conditional, so
both messages below are in the markup and neither renders.
This error text is present in the DOM and not shown.
So is this success text.