form-field

Accessible form field wrapper with label, input, and validation message. Enables CSS-only validation feedback using native HTML5 validation and the <output> element.

Attributes

Attribute Values Default Description
data-type checkbox, radio, file - Special styling for non-text inputs
data-no-icon boolean - Disable validation status icons
data-required boolean - Visual indicator (auto-detected from input)
data-valid boolean - Force valid state styling
data-invalid boolean - Force invalid state styling

Input Attributes (for JS enhancements)

Attribute Values Default Description
data-type (on input) otp, pin - Enable OTP/PIN multi-box enhancement
data-length (on input) 4, 6, 8 6 Number of digits for OTP/PIN input

Element Structure

The form-field element groups a label, input control, and optional validation message.

form-field +-- label (required, with for attribute) +-- input/textarea/select (required, with id matching label) +-- output (optional, for validation/help messages)
Child Element Purpose Key Attributes
<label> Accessible field label for (matches input id)
<input> / <textarea> / <select> Form control id, name, validation attrs, aria-describedby
<output> Validation/help message id, for, aria-live="polite"

Basic Text Input

At least 2 characters required

Code

<form-field> <label for="name">Full Name</label> <input type="text" id="name" name="name" required minlength="2" autocomplete="name" aria-describedby="name-msg" /> <output id="name-msg" for="name" aria-live="polite"> At least 2 characters required </output> </form-field>

Input Types

Email Input

Please enter a valid email address

Password Input

At least 8 characters required

Textarea

At least 10 characters required

Select Dropdown

Password Toggle

Password inputs are automatically enhanced with a show/hide toggle button when JavaScript is available. No extra markup is needed - just use type="password".

At least 8 characters required

The toggle button:

  • Appears automatically when JavaScript loads
  • Uses accessible ARIA attributes (aria-pressed, aria-label)
  • Works alongside validation icons (positions adjust automatically)
  • Preserves tab order and keyboard navigation

Code

<form-field> <label for="password">Password</label> <input type="password" id="password" name="password" required minlength="8" autocomplete="current-password" aria-describedby="password-msg" /> <output id="password-msg" class="error" for="password" aria-live="polite"> At least 8 characters required. </output> </form-field> <!-- JS auto-adds show/hide toggle button -->

Validation Status Icons

Form fields automatically display validation status icons after user interaction:

  • Checkmark appears when the field is valid
  • X appears when the field is invalid

Icons are positioned inside the input's padding and adjust automatically for password fields with toggle buttons.

Please enter a valid email

Disabling Icons

Use data-no-icon on the form-field to disable validation icons. Useful for search fields or other contexts where icons aren't needed.

Code

<!-- Default: validation icons appear --> <form-field> <label for="email">Email</label> <input type="email" id="email" name="email" required /> </form-field> <!-- Opt-out: no validation icons --> <form-field data-no-icon> <label for="search">Search</label> <input type="search" id="search" name="q" /> </form-field>

OTP/PIN Input

For verification codes, use data-type="otp" on the input. This creates a progressive enhancement pattern:

  • Without JavaScript: Works as a standard text input
  • With JavaScript: Enhances to a multi-box input with automatic focus management
Enter the 6-digit code

Features

  • Auto-focus moves to the next box after entering a digit
  • Backspace moves focus to the previous box
  • Arrow keys navigate between boxes
  • Paste support fills multiple boxes at once
  • Original input becomes hidden and stores the combined value

Code

<form-field data-no-icon> <label for="code">Verification code</label> <input type="text" id="code" name="code" data-type="otp" data-length="6" inputmode="numeric" pattern="[0-9]{6}" autocomplete="one-time-code" required aria-describedby="code-msg" /> <output id="code-msg" class="hint" for="code" aria-live="polite"> Enter the 6-digit code from your authenticator app. </output> </form-field> <!-- Works as text input without JS, multi-box with JS -->

4-Digit PIN Variant

Enter your 4-digit PIN

Output Message Classes

Use class="hint" and class="error" on output elements for different message types:

Class Visibility Use Case
.hint Always visible (hides when valid) Format requirements, help text
.error Only when invalid Error messages
3-20 characters, letters and numbers only. Please enter a valid username.

Code

<!-- Hint message - always visible --> <form-field> <label for="username">Username</label> <input type="text" id="username" name="username" required minlength="3" maxlength="20" aria-describedby="username-hint username-error" /> <output id="username-hint" class="hint" for="username"> 3-20 characters, letters and numbers only. </output> <output id="username-error" class="error" for="username" aria-live="polite"> Please enter a valid username. </output> </form-field>

Checkbox and Radio

Single Checkbox

Radio Group

Preferred Contact Method

Code

<!-- Single Checkbox --> <form-field data-type="checkbox"> <label> <input type="checkbox" id="terms" name="terms" required /> I agree to the Terms of Service </label> </form-field> <!-- Radio Group --> <fieldset> <legend>Preferred Contact Method</legend> <layout-stack data-layout-gap="xs"> <form-field data-type="radio"> <label> <input type="radio" name="contact" value="email" required /> Email </label> </form-field> <form-field data-type="radio"> <label> <input type="radio" name="contact" value="phone" /> Phone </label> </form-field> </layout-stack> </fieldset>

CSS-Only Validation

The form-field element uses the :user-valid and :user-invalid pseudo-classes to show validation states only after user interaction. This prevents premature error states on page load.

Try It - Type and then clear each field

Enter a valid email address Include https://

How It Works

/* Valid state - green border after user interacts */ form-field:has(input:user-valid) { & input { border-color: var(--color-success); } & output { color: var(--color-success); } } /* Invalid state - red border after user interacts */ form-field:has(input:user-invalid) { & input { border-color: var(--color-error); } & output { color: var(--color-error); } }

JS-Enhanced Validation

Add data-validate to a <form> to layer JS validation on top of the CSS foundation. This enables features CSS can't handle: custom error messages, cross-field validation, checkbox group constraints, and error summaries.

Without data-validate, everything works exactly as before — pure CSS validation via :user-valid/:user-invalid.

JS Validation Attributes

Attribute On Description
data-validate <form> Opt-in. No value = field-level errors (+ summary if present). "summary" = summary only.
data-message-required input / textarea / select Custom "required" error text
data-message-type input Custom "type mismatch" error text (email, url, etc.)
data-message-minlength input / textarea Custom "too short" error text
data-message-maxlength input / textarea Custom "too long" error text
data-message-min input Custom "range underflow" error text
data-message-max input Custom "range overflow" error text
data-message-pattern input Custom "pattern mismatch" error text
data-match input ID of field that must have the same value
data-message-match input Custom message when data-match fails
data-min-checked fieldset Minimum number of checked checkboxes
data-max-checked fieldset Maximum number of checked checkboxes
data-state="validating" form-field CSS hook for async validation spinner (CSS-only, no JS)

Custom Error Messages

Use data-message-* attributes on inputs to specify per-constraint error messages. The right message appears for the right validation failure.

Code

<form data-validate> <form-field> <label for="email">Email</label> <input type="email" id="email" name="email" required minlength="5" data-message-required="Email is required" data-message-type="Please enter a valid email address" data-message-minlength="Email seems too short" aria-describedby="email-error" /> <output id="email-error" class="error" for="email" aria-live="polite"></output> </form-field> <button type="submit">Submit</button> </form>

Password Confirmation

Use data-match to require two fields to have the same value. The most common use case is password confirmation.

Code

<form data-validate> <form-field> <label for="password">Password</label> <input type="password" id="password" name="password" required minlength="8" data-message-required="Password is required" data-message-minlength="At least 8 characters" autocomplete="new-password" aria-describedby="password-error" /> <output id="password-error" class="error" for="password" aria-live="polite"></output> </form-field> <form-field> <label for="confirm">Confirm Password</label> <input type="password" id="confirm" name="confirm" required data-match="password" data-message-required="Please confirm your password" data-message-match="Passwords do not match" autocomplete="new-password" aria-describedby="confirm-error" /> <output id="confirm-error" class="error" for="confirm" aria-live="polite"></output> </form-field> <button type="submit">Create Account</button> </form>

Checkbox Group Constraints

Use data-min-checked and data-max-checked on a <fieldset> to constrain how many checkboxes can be checked.

Favorite Seasons (1-2)

Code

<form data-validate> <fieldset data-min-checked="1" data-max-checked="3" data-message-min-checked="Select at least one" data-message-max-checked="Maximum 3 allowed"> <legend>Interests</legend> <form-field data-type="checkbox"> <label><input type="checkbox" name="interests" value="music" /> Music</label> </form-field> <form-field data-type="checkbox"> <label><input type="checkbox" name="interests" value="sport" /> Sport</label> </form-field> <form-field data-type="checkbox"> <label><input type="checkbox" name="interests" value="art" /> Art</label> </form-field> <form-field data-type="checkbox"> <label><input type="checkbox" name="interests" value="tech" /> Tech</label> </form-field> <output aria-live="polite"></output> </fieldset> <button type="submit">Save</button> </form>

Error Summary

Add an <output class="error-summary"> inside your form for a consolidated list of all errors on submit. Three display modes:

Mode Attribute Behavior
Field-level only data-validate (no summary output) Errors appear inline next to each field
Summary only data-validate="summary" Summary at top, field-level errors hidden
Both data-validate + summary output present Summary at top AND inline errors on each field

On submit failure, the summary lists all errors as links. Clicking a link scrolls to and focuses the relevant field. The summary auto-clears when all errors are fixed.

Code

<!-- Summary + field-level errors (both) --> <form data-validate> <output class="error-summary" role="alert" aria-live="polite" tabindex="-1"></output> <form-field> <label for="name">Name</label> <input id="name" type="text" required data-message-required="Name is required" aria-describedby="name-error" /> <output id="name-error" class="error" for="name" aria-live="polite"></output> </form-field> <form-field> <label for="email">Email</label> <input id="email" type="email" required data-message-required="Email is required" data-message-type="Enter a valid email" aria-describedby="email-error" /> <output id="email-error" class="error" for="email" aria-live="polite"></output> </form-field> <button type="submit">Submit</button> </form> <!-- Summary only (field-level errors hidden) --> <form data-validate="summary"> <output class="error-summary" role="alert" aria-live="polite" tabindex="-1"></output> <!-- ... fields ... --> </form>

Why Use <output> for Messages?

The <output> element is semantically ideal for validation messages:

  • Purpose-built: Represents the result of a calculation or user action
  • Native association: Has for attribute to link to input(s)
  • Accessible: Works naturally with aria-live for screen readers
  • Semantic distinction: Clearly different from static help text
  • No JavaScript required: Can show different states via CSS
Approach Semantics Accessibility Recommendation
<output> Result of action Native support Recommended
<span class="error"> None Manual ARIA needed Avoid
<div class="message"> None Manual ARIA needed Avoid

Complete Contact Form

Please enter your name (at least 2 characters) Please enter a valid email address Please enter your message (at least 10 characters)

Code

<form action="/api/contact" method="POST"> <layout-stack data-layout-gap="m"> <form-field> <label for="name">Name</label> <input type="text" id="name" name="name" required minlength="2" autofocus autocomplete="name" aria-describedby="name-msg" /> <output id="name-msg" for="name" aria-live="polite"> Please enter your name </output> </form-field> <form-field> <label for="email">Email</label> <input type="email" id="email" name="email" required autocomplete="email" aria-describedby="email-msg" /> <output id="email-msg" for="email" aria-live="polite"> Please enter a valid email </output> </form-field> <form-field> <label for="message">Message</label> <textarea id="message" name="message" required minlength="10" rows="5" aria-describedby="message-msg"></textarea> <output id="message-msg" for="message" aria-live="polite"> At least 10 characters </output> </form-field> <button type="submit">Send Message</button> </layout-stack> </form>

Accessibility Checklist

When building forms with form-field, ensure:

  • Every <input> has a <label> with matching for/id
  • Required fields use the required attribute
  • Validation messages use <output> with aria-live="polite"
  • Inputs link to messages via aria-describedby
  • Form controls have appropriate autocomplete values
  • Focus states are clearly visible
  • Error states don't rely on color alone (include text)
  • First input has autofocus for immediate interaction

HTML5 Validation Attributes

Use native validation attributes for client-side validation:

Attribute Purpose Example
required Field must have value <input required />
minlength Minimum character count <input minlength="2" />
maxlength Maximum character count <input maxlength="100" />
pattern Regex pattern <input pattern="[A-Za-z]+" />
type Input type validation type="email", type="url"
min/max Number/date range <input type="number" min="0" max="100" />

CSS Implementation

form-field { display: flex; flex-direction: column; gap: var(--size-xs); } form-field label:first-child { font-weight: var(--font-weight-medium); } form-field input:not([type="checkbox"]):not([type="radio"]), form-field select, form-field textarea { width: 100%; padding: var(--size-s) var(--size-m); border: 1px solid var(--color-border); border-radius: var(--radius-m); transition: border-color 0.2s, box-shadow 0.2s; } form-field input:focus, form-field select:focus, form-field textarea:focus { outline: none; border-color: var(--color-interactive); box-shadow: 0 0 0 3px oklch(from var(--color-interactive) l c h / 0.15); } form-field output { font-size: var(--font-size-sm); color: var(--color-text-muted); } /* Validation states (CSS-only) */ form-field input:user-valid:not(:placeholder-shown), form-field select:user-valid, form-field textarea:user-valid:not(:placeholder-shown) { border-color: var(--color-success); } form-field input:user-invalid:not(:placeholder-shown), form-field select:user-invalid, form-field textarea:user-invalid:not(:placeholder-shown) { border-color: var(--color-error); } form-field input:user-invalid:not(:placeholder-shown) ~ output, form-field select:user-invalid ~ output, form-field textarea:user-invalid:not(:placeholder-shown) ~ output { color: var(--color-error); } /* Checkbox/Radio styling */ form-field[data-type="checkbox"] label, form-field[data-type="radio"] label { display: flex; align-items: center; gap: var(--size-s); cursor: pointer; }

Related Elements