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.
Basic HTML
Section titled “Basic HTML”<div class="hc-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>Invalid state
Section titled “Invalid state”<div class="hc-field" data-invalid="true"> <label class="hc-field__label" for="email">Email</label> <input id="email" class="hc-input" name="email" type="email" aria-invalid="true" aria-describedby="email-error"> <p id="email-error" class="hc-field__message"> Enter a valid email address. </p></div>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, andaria-describedbypoints at the error message.
A server-rendered form should set both atomically when validation fails.
Composing a form
Section titled “Composing a form”A complete form needs nothing beyond kit classes — no app CSS for label layout, control widths, required markers, help text, or the actions row:
<form> <div class="hc-field"> <label class="hc-field__label" for="realm">Realm id</label> <input class="hc-input" id="realm" name="realmId" required placeholder="local" aria-describedby="realm-help"> <p class="hc-field__message" id="realm-help">Lowercase letters only.</p> </div>
<div class="hc-field"> <label class="hc-field__label" for="display">Display name</label> <input class="hc-input" id="display" name="displayName"> </div>
<!-- Related radios/checkboxes: the field is a <fieldset> --> <fieldset class="hc-field"> <legend class="hc-field__label">Mode</legend> <label><input type="radio" name="mode" value="standard" checked> Standard</label> <label><input type="radio" name="mode" value="strict"> Strict</label> </fieldset>
<!-- Actions row --> <div class="hc-cluster"> <button type="submit" class="hc-button" data-variant="primary">Create realm</button> <button type="button" class="hc-button" data-variant="ghost">Cancel</button> </div></form>Everything in that form is default behavior:
- Label association is native —
for/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-fieldis a grid (items stretch) andhc-input/hc-selectareinline-size: 100%by default, soform .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 withdata-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.
htmx usage
Section titled “htmx usage”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.
Client-side validation
Section titled “Client-side validation”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.).
Server-side validation errors
Section titled “Server-side validation errors”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.
Accessibility
Section titled “Accessibility”- The
<label>must reference the input’sidviafor. 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 usearia-describedbyto point at the message element so screen readers announce the error. - The message uses
--hc-field-message-colorfor help text and--hc-field-invalid-message-colorfor errors — both meet WCAG AA contrast against--hc-color-surfaceat the default tokens. - Do not rely on color alone. The error message text itself is the primary accessibility signal.
Theming tokens
Section titled “Theming tokens”Component tokens (in component.tokens.json):
| Token path | Purpose |
|---|---|
field.gap | Vertical spacing. |
field.label-color | Label color. |
field.label-weight | Label font weight. |
field.label-font-size | Label font size. |
field.message-color | Help text color. |
field.message-font-size | Help text font size. |
field.invalid-message-color | Error message color. |
CSS variables
Section titled “CSS variables”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 */