Overview
Cohesive set of form elements that share focus styles, helpers, and validation patterns.
- Form controls are styled with explicit classes, not by parent scope.
- Control classes:
.input,.textarea,.select,.check,.radio,.switch,.range,.file. - Reusable layout classes:
.field,.field-label,.field-help,.field-error,.field-group,.choice,.form-actions,.form-actions.sticky. - No floating labels.
- Proper focus style that preserves keyboard navigation.
.field-groupand.choiceprovide layout without depending on parent scope.- Customizable select is progressive enhancement only — the native select remains the baseline.
<form novalidate>
<div class="stack">
<label class="field">
<span class="field-label">Full name <span class="required-mark" aria-hidden="true">*</span></span>
<input class="input" type="text" autocomplete="name" required placeholder="Jane Doe" />
</label>
<label class="field">
<span class="field-label">Email address <span class="required-mark" aria-hidden="true">*</span></span>
<input class="input" type="email"
autocomplete="email"
required
aria-invalid="true"
aria-describedby="email-error"
value="jane@" />
<span class="field-error" id="email-error" role="alert">
Enter a valid email address
</span>
</label>
<label class="field">
<span class="field-label">Website</span>
<input class="input" type="url"
autocomplete="url"
aria-describedby="website-help"
placeholder="https://example.com" />
<span class="field-help" id="website-help">Your public portfolio or personal site</span>
</label>
<label class="field">
<span class="field-label">Bio</span>
<textarea class="textarea" aria-describedby="bio-help"
maxlength="200"
placeholder="Tell us a bit about yourself…"></textarea>
<span class="field-help" id="bio-help">Max 200 characters. Shown on your public profile.</span>
</label>
<label class="field">
<span class="field-label">Language</span>
<select class="select" autocomplete="language">
<option value="">Choose a language</option>
<optgroup label="Available languages">
<option value="en" selected>English</option>
<option value="fr">Français</option>
<option value="nl">Nederlands</option>
</optgroup>
<optgroup label="Not yet available">
<option value="de" disabled>Deutsch</option>
</optgroup>
</select>
</label>
<fieldset class="field-group">
<legend class="field-label">Availability</legend>
<div class="stack">
<label class="choice">
<input class="radio" type="radio" name="availability" value="available" checked />
<span>Available for work</span>
</label>
<label class="choice">
<input class="radio" type="radio" name="availability" value="open" />
<span>Open to opportunities</span>
</label>
<label class="choice">
<input class="radio" type="radio" name="availability" value="unavailable" />
<span>Not available</span>
</label>
</div>
</fieldset>
<label class="field">
<span class="field-label">Experience level</span>
<input class="range" type="range"
min="0"
max="10"
value="5"
aria-valuetext="5 years"
aria-describedby="range-help" />
<span class="field-help" id="range-help">Years of professional experience</span>
</label>
<label class="choice">
<input class="switch" type="checkbox" role="switch" checked />
<span>
<span class="field-label">Email notifications</span>
<span class="field-help">Receive updates about your account activity</span>
</span>
</label>
<label class="choice">
<input class="check" type="checkbox" />
<span>
I agree to the <a href="#">terms of service</a>
<span class="field-help">Required to create an account</span>
</span>
</label>
<label class="field">
<span class="field-label">Profile picture</span>
<input class="file" type="file" accept="image/*" aria-describedby="file-help" />
<span class="field-help" id="file-help">JPG, PNG or WebP — max 2 MB</span>
</label>
<label class="field">
<span class="field-label">Username</span>
<input class="input" type="text"
value="janedoe"
disabled
aria-describedby="username-help" />
<span class="field-help" id="username-help">Contact support to change your username</span>
</label>
</div>
<div class="form-actions">
<button type="button" class="btn outline">Cancel</button>
<button type="submit" class="btn primary">Save changes</button>
</div>
</form>
Notes
requiredis enough on native inputs.aria-required="true"is redundant and not used in these examples.requiredis the semantic source of truth. Add<span class="required-mark" aria-hidden="true">*</span>inside a label only when a visual required marker is wanted; the framework does not add one automatically..fieldis the canonical field wrapper. It works on<label>and<div>..field-labelis element-agnostic. Use it on<span>inside a wrapped label, on<label for="…">in a detached layout, or on<legend>inside a fieldset..field-groupstyles a<fieldset class="field-group">as a grouped field container.- Use native
<optgroup>anddisabledoptions in.selectcontrols when applicable; the customizable picker preserves both as a progressive enhancement. .choiceis the choice-label API. Use.checkor.radioon the nested control. The control goes first, the label group second, so multi-line labels align the control with the first line.- For a switch, use
class="switch"androle="switch". - Core range controls stay native and are enhanced by
accent-color. An optional richer range skin may come later if real projects need it. .form-actionscarries a default top margin. Override it with--form-actions-margin-block-start. It is class-only and may live inside or outside<form>. See Detached Actions below..joinvisually joins adjacent controls into a single unit. Use.join-addonfor static prefix/suffix content. The container still carriesrole="group"for accessibility.
Optional automatic required marks
If a form consistently marks every required field, opt into automatic visual markers in application CSS. Scope the rule to that form so other forms stay explicit, and retain a per-field opt-out:
CSS
form[data-required-marks="auto"]
.field:not([data-required-mark="none"]):has(:required)
> .field-label::after {
content: " *";
color: var(--danger);
}
required remains the accessible source of truth; this recipe adds only a visual cue. Use data-required-mark="none" on a .field when a required control should not receive the generated marker.