We'll never share your email.
Enter a valid email address.
Format: +1 555 555 5555
We'll never share your email.
Enter a valid email address.
This field is locked while the record is archived.
Markdown is supported.
/*
* Field-group — element (form)
*
* Canonical composite for a form field: Label + control + HelperText
* + Error. State is driven entirely by data-state on the wrapper —
* authors set data-state="error" before render; CSS handles visibility
* (Error replaces HelperText; aria-invalid is matched). No runtime.
*
* <div class="field-group">
* <label class="label" for="email">Email</label>
* <input class="input" type="email" id="email" />
* <p class="helper-text">We'll never share your email.</p>
* </div>
*
* <div class="field-group" data-state="error">
* <label class="label" for="email">Email</label>
* <input class="input" type="email" id="email" aria-invalid="true" />
* <p class="helper-text">We'll never share your email.</p>
* <p class="error">Enter a valid email address.</p>
* </div>
*/
@layer malevich.components {
.field-group {
--field-label-min: calc(var(--size-control-xl) * 2);
display: flex;
flex-direction: column;
gap: var(--space-gap-elements-s);
}
/* When error state is on, hide helper text (error replaces it). */
.field-group[data-state="error"] .helper-text {
display: none;
}
/* When error state is NOT on, hide any error element that may exist
* in DOM (so authors can author both and toggle state via data-state).
*/
.field-group:not([data-state="error"]) .error {
display: none;
}
/* Disabled state — when the control inside is disabled, dim the
* label and helper text. CSS :has() handles the propagation.
*/
.field-group:has(:disabled) .label,
.field-group:has(:disabled) .helper-text {
opacity: 0.5;
}
/* Variants */
.field-group.-horizontal {
flex-direction: row;
align-items: center;
gap: var(--space-gap-elements-m);
}
.field-group.-horizontal .label {
flex-shrink: 0;
min-inline-size: var(--field-label-min);
}
.field-group.-horizontal .helper-text,
.field-group.-horizontal .error {
inline-size: 100%;
}
}
This component is pure CSS — no JavaScript required.
# Field-Group — AGENTS.md
> Auto-generated from `field-group.docs.md` + the modifier applicability matrix.
> Edit those source files; this file regenerates on build.
## Identity
- **Component:** `field-group`
- **Layer:** elements
- **Status:** stable
- **Last updated:** 2026-05-19
## Summary
Any time you need a labeled form control with optional helper or error
## Variants
| Variant | Class | Use for |
|-------------|-----------------------------|--------------------------------------|
| Default | `.field-group` | Stacked column |
| Horizontal | `.field-group.-horizontal` | Label left, control right |
## Modifier applicability
Per ADR-0007. Modifiers attach via `data-{category}="value"` attributes.
| Category | Accepts |
|---|---|
| `background` | — |
| `surface` | — |
| `effect` | — |
| `shader` | — |
| `rhythm` | `compact`, `regular`, `spacious` |
| `motion` | any value |
| `behavior` | — |
## Anatomy
```html
<!-- 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.
## Tokens consumed
### From semantic tier
- `--space-gap-elements-s` / `-m` — internal gaps
### Component-tier (defined inline)
- `--field-label-min` — minimum label width in horizontal variant
## Accessibility contract
- The Label's `for` attribute targets the control's `id`.
- `aria-describedby` on 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's
`data-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.)
## Guidance
### Do
- Always set `data-state="error"` AND `aria-invalid="true"` together.
- Keep both helper-text and error in the DOM with stable IDs for
`aria-describedby`.
- Use `.field-group` as the wrapper even when only one or two of the
parts are present.
### Don't
- Don't toggle visibility with `hidden` or `style.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.