component :: Form Field

The .field component composes the bare form elements into label + control + hint + error message, with validation states driven entirely by CSS.

We'll never share it. That doesn't look like an email address.

Type something that isn’t an email address and tab away: the hint swaps for the error and the border turns red, no JavaScript involved.

FileDescriptionSource
component.field.cssField + fieldRow composition and statesGithub
elements.form.cssBare element styles + :user-invalid borderGithub
Field.astroThe Astro componentGithub
--field-* block in default themeTheme tokens (see table below)Github

HTML

<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email" required aria-describedby="email-hint" />
<small class="field_hint" id="email-hint">We'll never share it.</small>
<small class="field_error">That doesn't look like an email address.</small>
</div>

Errors use :user-invalid, so a pristine form never lights up red — only fields the user actually touched and got wrong. The error message is always in the markup and revealed by .field:has(:user-invalid). It is deliberately not referenced by aria-describedby: screen readers get the browser’s native validation message on submit, so wiring the visual error in as a description would double-announce it.

Custom properties

PropertyDescription
--field-spacingGap between label, control, and messages.
--field-hint-colorHint text color.
--field-error-colorError text color.
--input-border-color-invalidBorder color of a :user-invalid control.

Astro component

PropTypeDefaultDescription
labelstringThe visible label. Required.
hintstringundefinedHelp text under the control.
errorstring"Please fill out…"Message revealed when the control is :user-invalid.
classstringundefinedAdditional CSS classes on the .field wrapper.

Everything else (type, name, placeholder, required, pattern, minlength…) is passed through to the <input>. The label’s for uses id, falling back to name — set at least one.

To use another control (textarea, select…), put it in the default slot and give it the matching id:

<Field label="Message" id="message">
<textarea id="message" name="message" required></textarea>
</Field>

Recipes

Newsletter signup

.fieldRow lines fields and buttons up on one wrapping row:

That doesn't look like an email address.
<form class="fieldRow" method="post" action="/subscribe">
<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<button class="bt bt-primary" type="submit">Subscribe</button>
</form>

Contact form

Fieldset + the grid system for the two-column row:

Contact us
Please fill out this field correctly.
That doesn't look like an email address.
Please fill out this field correctly.
---
import Field from "../components/Field.astro";
---
<form method="post" action="/contact">
<fieldset>
<legend>Contact us</legend>
<p aria-hidden="true">Contact us</p>
<div class="grid" col="1" col-md="2">
<Field label="Name" name="name" required />
<Field label="Email" name="email" type="email" required />
</div>
<Field label="Message" id="message">
<textarea id="message" name="message" required></textarea>
</Field>
<button class="bt bt-primary" type="submit">Send</button>
</fieldset>
</form>