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

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>

With helper text

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 — single error

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>

Invalid — multiple errors

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.

  • Use at least 8 characters.
  • Include one number.
  • Include one capital letter.
<ul class="form-field-errors" id="ff-password-errors">
  <li>Use at least 8 characters.</li>
</ul>

Valid

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

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">

Checkbox and grouped choices

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.

Which entry year are you applying for?

Choose the entry year you're applying for.

<fieldset class="form-field is-invalid"><legend class="form-label">…</legend>

Feedback is hidden until the wrapper says otherwise

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.