Form Control
A wrapper that provides consistent label, helper text, and error messaging for form fields. Use to ensure all form inputs meet layout and accessibility standards. Don't apply custom label or error patterns outside this component.
Description
What it does
Form Control is the structural wrapper that every form field lives inside. It is not a field itself — it provides the consistent layout layer that surrounds inputs, dropdowns, text areas, checkboxes, and other controls.
Where it appears
Every form in Open Point and Social Point — consultation setup, stakeholder profiles, survey builders, settings screens, login and registration flows, and inline editing contexts.
Why it exists
Without Form Control, each team member who builds a form makes independent decisions about label positioning, error placement, spacing, and ARIA associations. Form Control eliminates that variation. Any field wrapped in it automatically gets the same layout, spacing, and accessibility wiring as every other field in the product.
Anatomy
| Part | Required? | Notes |
|---|---|---|
| Label | Required | Always visible above the field. Programmatically associated with the control via for/id or aria-labelledby. Never substitute placeholder text for a label. |
| Required indicator | Conditional | An asterisk (*) or "Required" text alongside the label when the field is mandatory. The visual indicator must have a text equivalent — not colour alone. |
| Field slot | Required | The interactive control. Can be any field component: Input, TextArea, Dropdown, RadioButton, Checkbox, Switch, etc. |
| Helper text | Optional | Below the field. Provides context that the label alone can't — format hints, length limits, examples. Associated with the field via aria-describedby. |
| Error message | Conditional | Replaces helper text when validation fails. Prefixed with an error icon. Associated via aria-describedby. Set aria-invalid="true" on the field element simultaneously. |
| Character count | Optional | Used with TextArea or constrained inputs. Updates live as the user types. Associated via aria-describedby. |
States
| State | Behaviour |
|---|---|
| Default | Label above, optional helper text below. Field in its resting state. |
| Error | Error message replaces helper text. Red text and error icon. Field receives aria-invalid="true". Triggered on blur or form submission — not on every keystroke. |
| Disabled | Label, field, and helper text all render at reduced opacity. The entire control is non-interactive. Communicate why the field is disabled nearby if the reason isn't obvious. |
Usage guidelines
Required fields
Only mark fields as required when they genuinely are. In a form where most fields are required, consider marking the optional ones instead and noting the convention at the top of the form.
The required indicator (asterisk or "Required") must always be accompanied by a text explanation — either visible near the top of the form ("Fields marked with * are required") or included in the label itself ("Full name (required)"). Never rely on the asterisk or red colour alone.
When to use
- Every form field — no exceptions. Input, TextArea, Dropdown, RadioButton, Checkbox, Switch, and all other form controls should live inside a Form Control wrapper.
- Even fields with unconventional layouts (inline forms, filter bars, search) should use Form Control where possible to preserve the accessibility wiring.
When not to use
- Standalone icon buttons or controls that aren't part of a form — these don't need a label/helper structure.
- Groups of related fields (e.g. a date with separate day/month/year inputs) — use a
<fieldset>and<legend>to wrap the group, with individual Form Controls inside for each field.
Do / Don't
Do
Always use a visible label. Every field must have one — even if the design shows a clean, label-free layout, the label must exist in the DOM and be associated with the field.
Don't
Don't use placeholder text as a substitute for a label. Placeholder text disappears the moment the user starts typing, leaving them with no reminder of what the field is for.
Do
Write error messages that tell users exactly what to do: "Enter a valid email address (e.g. name@example.com)" rather than "Invalid input".
Don't
Don't show error messages before the user has interacted with the field. Trigger validation on blur (when leaving the field) or on form submission — not on page load.
Do
Use helper text to set expectations before the user types — format, length, examples. "Must be at least 8 characters" is more useful than waiting for an error.
Don't
Don't use helper text to restate the label. It should add context the label doesn't already provide.
Layout & Spacing
Form Control uses a vertical stack layout. The label sits above the field, helper text and error messages sit below. Spacing is consistent regardless of which field component is inside.
| Element | Spec |
|---|---|
| Label → field gap | --op-space-8 (0.5rem) |
| Field → helper / error gap | --op-space-8 (0.5rem) |
| Between form controls (vertical) | --op-space-24 (1.5rem) |
| Label font size | --op-text-sm |
| Label font weight | 600 |
| Helper text font size | --op-text-sm |
| Error message font size | --op-text-sm |
Field sizes
Input, Select, Search and Textarea share one size scale, so a field lines up with a button of the same size. Heights are fixed for Input, Select and Search; a Textarea grows with its content and is padded instead.
| Size | Height | Padding (H) | Gap | Font size | Textarea padding (V) |
|---|---|---|---|---|---|
| Extra small | 24px | 8px | 4px | 12px | 4px |
| Small | 32px | 12px | 8px | 14px | 8px |
| Medium (default) | 40px | 16px | 8px | 16px | 10px |
| Large | 48px | 20px | 12px | 16px | 12px |
| Extra large | 56px | 24px | 12px | 20px | 16px |
Component tokens
The field tokens are framework-neutral CSS variables, so any framework (including React Aria components) can use them without Web Awesome. Colours follow Light and Dark mode and the product theme. Every token aliases a Semantic token, never a Primitive.
| Token | What it sets |
|---|---|
--orbit-form-control-outlined-background, -filled-outlined-background |
Field fill for each appearance. |
--orbit-form-control-border |
Field outline. Uses the strong border colour, so it meets 3:1 against the surface in Light and Dark. |
--orbit-form-control-text, -placeholder, -icon |
Entered value, placeholder and icon colours. The placeholder uses quiet text so it meets 4.5:1. |
--orbit-form-control-label-color, -label-font-weight, -hint-color, -hint-font-size |
Label and hint text. |
--orbit-form-control-state-hover |
Translucent overlay drawn over the fill on hover. |
--orbit-form-control-invalid-border, -invalid-message |
Outline and message colour when the field is invalid. |
--orbit-form-control-disabled-background, -border, -text |
Disabled colours. |
| `--orbit-form-control-size-{xs | s |
| `--orbit-form-control-textarea-size-{xs | s |
--orbit-form-control-select-multiple-padding-inline-start |
Left padding of a multi-select, smaller because tag chips are boxes. |
--orbit-form-control-radius, -radius-pill |
Corner radius. Pill is used by search fields. |
--orbit-form-control-border-width, `-focus-ring-{width |
offset |
--orbit-form-control-transition-duration, -transition-easing, -font-family, -value-font-weight |
Motion and type. |
States are Default, Hover, Invalid and Disabled. Focus is shown with the focus ring, not a separate variant.
| Required indicator | Asterisk inline with label, --op-space-4 gap, colour --op-color-status-error |
Tokens
| Part | Token | Value |
|---|---|---|
| Label text | --op-color-text-primary | --op-color-neutral-10 |
| Required indicator | --op-color-status-error | --op-color-red-50 |
| Helper text | --op-color-text-muted | --op-color-neutral-30 |
| Error message text | --op-color-status-error | --op-color-red-50 |
| Error icon | --op-color-status-error | --op-color-red-50 |
| Disabled opacity | — | 0.5 |
Engineering notes
- The Form Control wrapper is responsible for the
aria-describedbyrelationship — the field component itself should expose anid, and the Form Control generates matching IDs for helper text and error message elements. - Helper text and error messages should not both be present in the DOM simultaneously with active
aria-describedbylinks — toggle visibility at the element level or swap thearia-describedbyvalue. Avoiddisplay: noneon elements still referenced byaria-describedby. - The error message element should carry
role="alert"so it's announced immediately when it appears — this removes the need for the user to refocus the field to hear the error. - Use the
autocompleteattribute on any field where the browser can assist — name, email, address, phone. This significantly reduces burden on users who rely on autofill, including users with motor impairments and cognitive disabilities. - For groups of related fields (date parts, name parts, checkbox groups), always use
<fieldset>and<legend>rather than a heading + div wrapper.
Focus
Every field must have a programmatically associated label — not just a visible text element nearby. There are two valid ways to achieve this:
| Method | How | When to use |
|---|---|---|
HTML <label> with for |
Set for="field-id" on the <label> and a matching id on the field. |
Native inputs. Preferred — clicking the label focuses the field automatically. |
aria-labelledby |
Set aria-labelledby="label-id" on the field, pointing to the ID of the label element. |
Custom components or cases where a <label> element can't be used. |
ARIA
| Role or attribute | When to use | Example |
|---|---|---|
aria-required="true" | Applied to the field element. All required fields. Pair with a visible required indicator. | <input aria-required="true" aria-describedby="field-hint"> |
aria-invalid="true" | Applied to the field element. When the field has a validation error. Set after user interaction, not on load. | <input aria-invalid="true" aria-describedby="field-error"> |
aria-describedby | Applied to the field element. Points to the ID of the helper text or error message element. | <input aria-describedby="field-error"><span id="field-error">Enter a valid email address.</span> |
aria-labelledby | Applied to the field element. When a native <label for> can't be used. Points to the label element's ID. | <input aria-labelledby="field-label"><span id="field-label">Email address</span> |
role="alert" | Applied to the error message element. Causes the error to be announced immediately when it appears, without waiting for focus. | <span id="field-error" role="alert">Enter a valid email address.</span> |
aria-hidden="true" | Applied to the required asterisk (*). Hides the symbol from screen readers — aria-required on the field already communicates this. | <label>Email <span aria-hidden="true">*</span></label> |
How to apply it
A red asterisk alone is not sufficient — colour cannot be the only way to convey required status. Always pair the asterisk with a text explanation: either a form-level note ("Fields marked with * are required") or the word "required" included in the label itself. The asterisk should carry aria-hidden="true" so screen readers don't read it as a symbol — the aria-required attribute on the field communicates the same information programmatically.
Helper text and error messages
Helper text and error messages must be programmatically associated with the field using aria-describedby. Visual proximity alone is not enough — screen readers need an explicit link.
When a field has both helper text and an error message, aria-describedby should point to the currently visible one. On error, replace the helper text element's content with the error message (or swap which element is visible) and update aria-describedby accordingly. Adding role="alert" to the error element causes screen readers to announce it immediately without waiting for the user to focus the field.
Required fields
Use aria-required="true" on the field element for required fields. This is separate from the visual required indicator — both are needed. The HTML required attribute also works for native inputs and implicitly sets aria-required, but be aware it triggers browser-native validation UI which may conflict with custom error handling.
Grouped fields
When multiple related fields form a logical unit — a date split across day/month/year fields, a name split across first/last, a group of checkboxes — wrap them in a <fieldset> with a <legend> that names the group. Screen readers announce the legend when focus enters any field in the group, giving users the context they need.
Don't use a plain heading or a <div> for this — <fieldset> + <legend> is the correct semantic structure and the only one that reliably works across screen readers.
Watch out
Don't set aria-invalid="true" on page load before the user has interacted with the field. Screen readers announce the invalid state when focus arrives — marking every field as invalid on load creates a disorienting experience before the user has had a chance to fill anything in. Set aria-invalid on blur or after form submission.
Things to avoid
- Placeholder text as a label substitute — placeholder disappears on input, leaving users with no reminder of what the field expects. Always use a persistent visible label.
- Labels that aren't programmatically associated — a
<div>styled to look like a label isn't a label. Screen readers won't connect it to the field. Use<label for>oraria-labelledby. - Error messages not linked to the field — an error message positioned visually below a field isn't automatically read when the field receives focus. Always use
aria-describedbyto link them. - Colour alone to convey required or error state — a red asterisk or red border is not sufficient on its own. Always pair colour with text or iconography.
- Showing all fields as invalid on load — setting
aria-invalid="true"before the user has interacted is disorienting for screen reader users. Validate on blur or on submit. - Vague error messages — "This field is invalid" tells users nothing. Error messages must say what went wrong and, where possible, how to fix it.
- Using headings instead of
<fieldset>for field groups — headings don't create the programmatic group relationship that screen readers use. Use<fieldset>+<legend>for related fields.