Skip to content

Field

hc-field is a thin composition class that groups a label, a control, and an optional help or error message. Validity is expressed with the standard aria-invalid attribute on the input, plus data-invalid="true" on the wrapper so the message and border colors update together.

Use your work email.

Enter a valid email address.

Two attributes work together:

  • data-invalid="true" on the wrapper switches the message color and reinforces the input border. It is a visual hook.
  • aria-invalid="true" on the input is the accessibility signal, and aria-describedby points at the error message.

A server-rendered form should set both atomically when validation fails.

A complete form needs nothing beyond kit classes — no app CSS for label layout, control widths, required markers, help text, or the actions row:

Lowercase letters only.

Mode

Everything in that form is default behavior:

  • Label association is nativefor/id; clicking the label focuses the control. (Implicit wrapping also works, but the explicit form is easier to style and target.)
  • The required asterisk is automatic — any field containing a [required] control marks its label; no <span class="req">*</span>.
  • Controls fill the field.hc-field is a grid (items stretch) and hc-input / hc-select are inline-size: 100% by default, so form .hc-input { width: 100% }-style app CSS is unnecessary.
  • Help text is .hc-field__message; the error slot (.hc-field__error) is filled by client-side validation or the server — author it only if you want to reserve the space.
  • A radio/checkbox group is a fieldset.hc-field — the kit strips the native fieldset chrome; the <legend> takes the label class.
  • The actions row is a .hc-cluster (wrapping flex row). For an end-aligned or keyboard-navigable control strip, use Toolbar with data-hc-spacer.

Spacing between fields is the form’s concern, not the field’s — use .hc-stack (or a display:grid; gap rule) on the form.

For server-side validation, return the entire field from the server and swap it back in place. Setting data-invalid and aria-invalid on the returned HTML automatically restyles the field.

<form
class="hc-form"
data-hx-post="/users"
data-hx-target="this"
data-hx-swap="outerHTML">
<div class="hc-field" id="email-field">
<label class="hc-field__label" for="email">Email</label>
<input id="email" class="hc-input" name="email" type="email">
<p class="hc-field__message">Use your work email.</p>
</div>
<button class="hc-button" data-variant="primary" type="submit">
Create account
</button>
</form>

The server returns the same <form> (or just the failing <div class="hc-field" id="email-field" data-invalid="true">…) with the appropriate attributes and a descriptive message.

Native HTML constraint validation (required, type="email", pattern, min / max, minlength…) is wired into the field with no per-field code.

Styling needs no JavaScript. Each control reacts to the standard :user-invalid pseudo-class — it turns invalid only after the user has interacted (blurred the field or submitted the form), never on first paint. A required control also adds an asterisk to its field’s label automatically.

Messages and ARIA come from installValidation() (included in the auto-init /behaviors bundle). On blur — and live thereafter — it writes the control’s native, localized validationMessage into a .hc-field__error element (created if absent), sets aria-invalid="true" on the control and data-invalid="true" on the field, and points the control’s aria-describedby at the error. When the control becomes valid again, it clears all of that. On submit, the browser’s default bubble is replaced by the inline message and the invalid submit is still blocked.

<form>
<div class="hc-field">
<label class="hc-field__label" for="email">Email</label>
<input id="email" class="hc-input" name="email" type="email" required>
<!-- installValidation() fills this on demand; you can also author it -->
<p class="hc-field__error" aria-live="polite"></p>
</div>
<button class="hc-button" data-variant="primary" type="submit">Submit</button>
</form>

The message text (--hc-field-invalid-message-color) and the :user-invalid border share the same error tokens as the manual aria-invalid / data-invalid hooks, so server-rendered and client-side errors look identical. Combine the two: let the client catch native constraints immediately, and the server return data-invalid="true" for rules only it can check (uniqueness, etc.).

For rules only the server can check (uniqueness, conflicts), the field-errors recipe defines a small alert fragment the server returns on a failed submission. installFieldErrors() (also in the auto-init bundle) distributes each error into the matching field’s .hc-field__error with exactly the ARIA wiring described above, clears it when the user edits the field, and lets data-message-key resolve through the i18n catalog. Native constraint messages take precedence on the same control.

  • The <label> must reference the input’s id via for. Implicit labels (wrapping the input in <label>) are also valid but harder to style; the explicit form is recommended.
  • For invalid fields, set aria-invalid="true" and use aria-describedby to point at the message element so screen readers announce the error.
  • The message uses --hc-field-message-color for help text and --hc-field-invalid-message-color for errors — both meet WCAG AA contrast against --hc-color-surface at the default tokens.
  • Do not rely on color alone. The error message text itself is the primary accessibility signal.

Component tokens (in component.tokens.json):

Token pathPurpose
field.gapVertical spacing.
field.label-colorLabel color.
field.label-weightLabel font weight.
field.label-font-sizeLabel font size.
field.message-colorHelp text color.
field.message-font-sizeHelp text font size.
field.invalid-message-colorError message color.
Show the generated CSS variables
--hc-field-gap
--hc-field-label-color | -weight | -font-size
--hc-field-message-color | -font-size
--hc-field-invalid-message-color
--hc-field-required-color /* the required asterisk; defaults to --hc-color-error */
  • Input — the control inside the field.
  • Button — submits the form that the field belongs to.