Field-group

Normal

We'll never share your email.

Enter a valid email address.

Format: +1 555 555 5555

Error state

We'll never share your email.

Enter a valid email address.

Disabled

This field is locked while the record is archived.

Horizontal

With textarea

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.