FormField
A Label + HelperText + ErrorText composition wrapper for a single form control.
Overview
FormField composes a Label + HelperText + ErrorText around a single form control, auto-wiring
aria-describedby/aria-invalid where the platform allows it. It’s the foundation for the
upcoming M12 form-patterns milestone — reach for it whenever a control needs a label, a helper
message, or an error, instead of hand-rolling the id/ARIA wiring per screen.
On React, don’t also pass label/error directly to a wrapped Input/Textarea —
FormField owns rendering those; the wrapped control reads its id and ARIA attributes from
context instead.
Anatomy
No sketchy box — FormField is pure layout and typography. It renders (in order): an optional
label, the wrapped control (via children on React/React Native, or a <slot> on Web
Components), an optional helper-text line, and an optional error line. If both helperText and
error are supplied, both render simultaneously — there is no hide-on-error switching.
Variants & States
| State | Status | Notes |
|---|---|---|
| Label only | Implemented | — |
| Helper text | Implemented | — |
| Error | Implemented | Rendered with role="alert". |
| Helper text + error together | Implemented | Both render at once — no hide-on-error switching. |
| No label/helper/error | Implemented | Renders only the wrapped control — a no-op decoration. |
Usage
Do
- Use FormField to wrap a single Input/Textarea that needs a label, helper text, or an error.
- On React Native, use FormField's label to give Input a label (it has none of its own) — omit it when wrapping a control that already renders its own label (Textarea, Checkbox, Radio, Switch, Select, Slider).
- Prefer passing label/error to FormField itself on React rather than the wrapped control — the wrapped control still renders its own if FormField was not given one, so a control-level label/error is never silently lost.
Don't
- Don't wrap more than one control in a single FormField on any platform — it composes around exactly one.
Accessibility
This is the single most important section — the wiring mechanism is genuinely different on each platform:
- React:
FormFieldprovides{ id, describedByIds, invalid }via context.InputandTextarearead it when present, computing their ownid/aria-invalid/aria-describedbyfrom it — but the wrapped control’s ownlabel/errorstill render ifFormFielddidn’t supply one, so passing both never silently loses a field’s label or error. Checkbox/Radio/Switch/Select/ Slider don’t consume this context yet — they have no error concept of their own, so wrapping them renders the label/helper/error text but wires nothing into them (a separate, larger future undertaking once those components gain their own error concept). - Web Components:
<gh-form-field>auto-wiresid/aria-describedby/aria-invaliddirectly onto its slotted control — a string-formaria-describedbycan’t reference elements insidegh-form-field’s own shadow root from a light-DOM sibling (ID references never cross a shadow boundary), so it reaches into its own light-DOM child and sets the attributes on it imperatively instead (ordinary, unrestricted DOM access from the host’s own script — only style encapsulation and slot projection are shadow-scoped). Re-syncs onslotchangetoo, if the wrapped control is swapped out after mount. - React Native: no id/
aria-describedbyequivalent exists on this platform at all.FormFieldis a visual composition only — there is no automatic accessibility linkage between the helper/error text and the wrapped control. A screen-reader user focusing the control will not automatically hear the helper/error text. This is a real, permanent platform gap, not something faked viaaccessibilityHintcloning. - See the Accessibility Guide for general conventions.
Content
The label names the value being collected (“Email”), not an instruction. Helper text explains a format or constraint before the user makes a mistake (“Letters, numbers, and underscores only”); error text explains what went wrong and how to fix it, after validation.
Props API
React@ghds/react
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Visible label for the wrapped control. |
helperText | string | — | Non-error descriptive text, always shown regardless of error state. |
error | string | — | Error message. When set, the wrapped control is marked invalid. |
id | string | — | Id applied to the wrapped control (and used to derive helper/error ids). |
children | ReactNode | — | A single form control — reads id/aria-invalid/aria-describedby from context. |
Web Components@ghds/web-components
| Prop | Type | Default | Description |
|---|---|---|---|
for | string | — | Id of the wrapped control — mirrors native <label for>. Auto-assigned to the slotted control if it has none. |
label | string | — | Visible label for the wrapped control. |
helper-text | string | — | Non-error descriptive text (property: helperText). |
error | string | — | Error message. Rendered with role="alert" when set; also sets aria-invalid on the slotted control. |
helperId | string (readonly getter) | — | Computed as `${baseId}-helper`, where `baseId` is `for` if set, otherwise an internally generated id. |
errorId | string (readonly getter) | — | Computed as `${baseId}-error`, where `baseId` is `for` if set, otherwise an internally generated id. |
React Native@ghds/react-native
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Visible label. Primarily useful for wrapping Input, which has no label prop of its own. |
helperText | string | — | Non-error descriptive text, always shown regardless of error state. |
error | string | — | Error message, shown alongside helperText if both are set. |
children | ReactNode | — | A single form control. |
testID | string | — | Test handle for queries. |
Code examples
// React — @ghds/react (wraps one control; label/error render on FormField)
import { FormField } from '@ghds/react/form-field';
import { Input } from '@ghds/react/input';
<FormField label="Password" error="Must be at least 8 characters">
<Input type="password" />
</FormField>;
<!-- Web Components — @ghds/web-components (auto-wires id/aria onto the slotted control) -->
<script type="module">
import '@ghds/web-components/form-field';
</script>
<gh-form-field for="password" label="Password" error="Must be at least 8 characters">
<input type="password" />
</gh-form-field>
// React Native — @ghds/react-native (visual composition only; no ARIA linkage on this platform)
import { FormField } from '@ghds/react-native/form-field';
import { Input } from '@ghds/react-native/input';
<FormField label="Email" error={emailError}>
<Input value={email} onChangeText={setEmail} />
</FormField>;
Live Demo
React and Web Components render live below. React Native can’t run in a browser without additional
react-native-web wiring (not set up in this site), so its usage is shown as a code sample instead.