Field-group
Canonical composite for a form field: Label + control + HelperText +
Error. State is driven by a single data-state attribute on the
wrapper — no JavaScript required. Per ADR-0013 + the answered design
question recorded with this component.
When to use
Any time you need a labeled form control with optional helper or error text. The component owns the spacing, error visibility, and the relationship between elements.
Anatomy
<!-- Normal state -->
<div class="field-group">
<label class="label" for="email" data-required="true">Email</label>
<input class="input" type="email" id="email"
aria-describedby="email-hint email-error" />
<p class="helper-text" id="email-hint">We'll never share your email.</p>
<p class="error" id="email-error">Enter a valid email address.</p>
</div>
<!-- Error state -->
<div class="field-group" data-state="error">
<label class="label" for="email" data-required="true">Email</label>
<input class="input" type="email" id="email"
aria-invalid="true"
aria-describedby="email-hint email-error" />
<p class="helper-text" id="email-hint">We'll never share your email.</p>
<p class="error" id="email-error">Enter a valid email address.</p>
</div>
Visibility rule: the .helper-text and .error may both live in
the DOM. CSS hides whichever is not active based on data-state:
- Default: error is hidden, helper-text is visible.
data-state="error": error is visible, helper-text is hidden.
This lets the author render both elements with stable IDs (for
aria-describedby) and toggle state via the wrapper attribute. No
JS-driven DOM mutation.
Variants
| Variant | Class | Use for |
|---|---|---|
| Default | .field-group |
Stacked column |
| Horizontal | .field-group.-horizontal |
Label left, control right |
Tokens used
From semantic tier
--space-gap-elements-s/-m— internal gaps
Component-tier (defined inline)
--field-label-min— minimum label width in horizontal variant
Accessibility
- The Label's
forattribute targets the control'sid. aria-describedbyon the control points to the helper-text id + error id (space-separated). Both ids stay valid as elements remain in the DOM.- The control's
aria-invalid="true"aligns with the wrapper'sdata-state="error". The component does not toggle one from the other automatically — set both at render time. (A v1.1 runtime enhancement may sync them.)
Edge cases
- Disabled control: CSS
:has(:disabled)dims the label and helper text via opacity, so the field reads as disabled at a glance. - No helper text or no error: acceptable. Render only the elements the field actually needs.
- Horizontal layout on narrow screens: authors should switch back to vertical via responsive class or container query — this component doesn't auto-collapse.
Do
- Always set
data-state="error"ANDaria-invalid="true"together. - Keep both helper-text and error in the DOM with stable IDs for
aria-describedby. - Use
.field-groupas the wrapper even when only one or two of the parts are present.
Don't
- Don't toggle visibility with
hiddenorstyle.display. The attribute-driven CSS handles it. - Don't put two controls in one Field-group. If two fields belong together (e.g. first + last name), use two Field-groups in a horizontal layout.