| raw | true |
|---|---|
| title | Field |
| description | One form control with its label, an optional hint, and an error that shows itself when the control is invalid. |
| nav_group | Forms and Actions |
| nav_order | 5 |
One form control with its label, an optional hint, and an error that shows itself when the control is invalid.
View Code
<div class="stack" data-gap="lg">
<div class="field">
<label for="email">Email</label>
<input id="email" type="email" required aria-describedby="email-hint email-error">
<p id="email-hint" data-hint>We only use it to sign you in.</p>
<p id="email-error" data-error>Enter an address with an @ in it.</p>
</div>
<div class="field">
<input id="dark" type="checkbox" role="switch">
<label for="dark">Dark mode</label>
</div>
<div class="field">
<label for="quality">Quality</label>
<output for="quality" aria-hidden="true"></output>
<input id="quality" type="range" min="0" max="100" value="70">
</div>
<fieldset class="field">
<legend>Notify me by</legend>
<div class="field"><input id="n-email" type="checkbox" name="notify" value="email" checked><label for="n-email">Email</label></div>
<div class="field"><input id="n-sms" type="checkbox" name="notify" value="sms"><label for="n-sms">Text message</label></div>
</fieldset>
<fieldset class="field">
<legend>Plan</legend>
<div class="field"><input id="plan-free" type="radio" name="plan" value="free" checked><label for="plan-free">Free</label></div>
<div class="field"><input id="plan-pro" type="radio" name="plan" value="pro"><label for="plan-pro">Pro</label></div>
</fieldset>
</div>Every control in a form: text, email, number, select, textarea, checkbox, radio, switch, range. A form is a stack of fields and a button; the field owns what bare HTML cannot, the label's link to its control, the help text, and the error.
A tight column: label, control, hint, error. The control is a native element styled to the control tokens, so a theme that changes --yeti-control-radius changes every input. The error is hidden until the control is invalid and the visitor has touched it (:user-invalid), or until you set aria-invalid="true" after a server round trip; then it shows and the border turns to the alert color. A required control gets a marker after its label. A checkbox or radio is laid out inline automatically, label after the control, and its checked mark is a variant-colored centre inside a ring of the surface color.
<fieldset class="field">
<legend>Notify me by</legend>
<div class="field"><input id="n-email" type="checkbox" name="notify" value="email"><label for="n-email">Email</label></div>
<div class="field"><input id="n-sms" type="checkbox" name="notify" value="sms"><label for="n-sms">Text message</label></div>
<p data-hint>Pick as many as you like.</p>
</fieldset>A checkbox with role="switch" becomes a switch: a track with a thumb that slides to the end and takes the field's color when on. Off, the track is --yeti-switch-track and the thumb --yeti-switch-thumb, the control border and surface by default; a theme that darkens the control border sets these so off stays distinct from on, and the off track must still clear 3:1 against the page surface. A range input gets a thin track and a round thumb in the field's color, the height of a control so it is easy to grab.
The filled part of the track is --yeti-range-value, how far along the value sits from 0 to 1, because CSS cannot read an input's value. range.js keeps it in step, and writes the value into an output placed before the input, which is drawn over the thumb. Give the output aria-hidden: the input announces its own value and a screen reader should not hear it twice. Leave the output out of a page that does not load the module, or it stays empty.
The fill stops where the thumb's centre is, not at that share of the width. A thumb's centre only travels from half a thumb in to half a thumb from the end, so a plain percentage runs ahead of it, by eleven pixels at each end on a track this wide. The stylesheet makes that correction, because the thumb's width is an em it already knows and script would have to measure it again on every resize.
Without the module the track sits wherever --yeti-range-value says, so set it on the input for a static one.
<div class="field"><input id="dark" type="checkbox" role="switch"><label for="dark">Dark mode</label></div>
<div class="field"><label for="quality">Quality</label><output for="quality" aria-hidden="true"></output><input id="quality" type="range" min="0" max="100" value="70"></div><div class="field"><label for="volume">Volume</label><input id="volume" type="range" min="0" max="100" value="40"></div>Set by hand, it is a number rather than a percentage: style="--yeti-range-value: 0.7".
validate.js is the field's second module, and it is about the form rather than one control. On submit it finds every invalid control in the form, inside a .field or not, and sets aria-invalid="true" on each — which is the same attribute a server round trip would set, so the error shows and the border turns at once — writes the browser's own message into the empty [data-error] of the nearest field that has one, focuses the first control, and stops the submission. A control outside every field is counted and stops the submit like any other; there is simply nowhere to put its message, and under novalidate nothing else was going to stop it. A radio or checkbox group gets its message on the fieldset.field that carries the slot, not on the bare .field around each input. It dispatches yeti:invalid on the form with the controls it found, so a page can count them, scroll a summary into view, or send them somewhere. Typing or picking a valid value clears the mark again.
Give a form that loads it novalidate. Without that attribute the browser opens its own bubble on the first invalid control and never fires a submit event at all, so the module never runs and the page gets the bubble instead of its own error text. An error element you fill in yourself is never overwritten; an empty one is the slot the module writes into.
<form class="stack" data-gap="md" novalidate>
<div class="field">
<label for="signup-email">Email</label>
<input id="signup-email" type="email" required aria-describedby="signup-email-error">
<p id="signup-email-error" data-error></p>
</div>
<button class="button" type="submit">Sign up</button>
</form>The label must point at the control with for and the control must carry that id; Yeti's validator refuses an example without the pair. Put the hint's and the error's ids in the control's aria-describedby, so a screen reader hears the help text with the control and the error the moment it appears. Errors found on the server are shown with aria-invalid="true". The required marker is a visual echo of the required attribute, which is what is announced.
| Attribute | Type | Values | Default | Description |
|---|---|---|---|---|
data-variant |
enum | primary, secondary, success, warning, alert, danger, neutral, black, white |
primary |
The color of a checked checkbox or radio. |
data-size |
enum | sm, md, lg |
md |
Scales the control's height and text. |
data-inline |
boolean | Put the label beside the control. Checkboxes and radios are inline without it. |
Attributes that descendants carry, not the root.
| Attribute | Type | Values | On | Description |
|---|---|---|---|---|
data-hint |
boolean | > * |
Help text, referenced by the control's aria-describedby. | |
data-error |
boolean | > * |
The error message, hidden until the control is invalid. |
> label: 0 to 1. The label, with for pointing at the control's id. Required unless the field is a fieldset with a legend.> legend: 0 to 1. The legend, when the field is a fieldset grouping several controls.> input: 0 to 1. The control.> select: 0 to 1. The control.> textarea: 0 to 1. The control.> .affix: 0 to 1. The control slot as an affix: a control with attachments, or two controls joined.> output: 0 to 1. A range's value, written by range.js and drawn over the thumb. Put it before the input. Leave it out on a page that does not load the module, which would leave it empty.> [data-hint]: 0 to 1. Help text, referenced by the control's aria-describedby.> [data-error]: 0 to 1. The error message, hidden until the control is invalid.
| Token | Description |
|---|---|
--yeti-field-gap |
Space between label, control, and hint. |
--yeti-control-size |
Minimum height of the control. |
--yeti-control-radius |
Corner of the control. |
--yeti-control-border |
Border of the control at rest. |
--yeti-switch-track |
Fill and border of an off switch's track. |
--yeti-switch-thumb |
The thumb of an off switch. |
--yeti-range-value |
How far along a range's track the value sits, 0 to 1. range.js sets it; set it on the input yourself for a static one. |
--yeti-control-surface |
Background of the control. |
--yeti-control-chevron |
The select's chevron image. |
--yeti-color-alert |
Border of an invalid control. |
--yeti-color-primary |
The default variant's color, when data-variant is absent. |
--yeti-color-primary-subtle |
The default variant's tint. |
--yeti-color-primary-soft |
The default variant's soft stop. |
--yeti-color-primary-strong |
The default variant's strong stop. |
--yeti-color-primary-text |
The default variant's text color. |
--yeti-on-primary |
Text on the default variant's color. |
--yeti-text-md |
Text size when data-size is absent. |
--yeti-space-sm |
The space step when data-size is absent; a fieldset's padding follows it. |
--yeti-weight-strong |
Weight of the label or legend. |
--yeti-color-text |
Text of the control. |
--yeti-border-width |
Border width of every control, and of a fieldset. |
--yeti-duration-fast |
How long a control's border and a switch take to change. |
--yeti-ease |
The curve of that transition. |
--yeti-color-border-strong |
Border of a focused control. |
--yeti-radius-full |
Corners of the switch and of the range track. |
--yeti-space-xs |
Gap of an inline field, and a legend's inline padding. |
--yeti-text-sm |
Text size of the hint and the error. |
--yeti-color-text-muted |
The hint. |
--yeti-color-alert-text |
The error, and the required marker. |
--yeti-space-md |
Padding of a fieldset. |
Internal tokens (may change between minor versions)
--_yeti-range-thumb--_yeti-range-at--_yeti-variant--_yeti-on-variant--_yeti-size-text--_yeti-size-space--_yeti-variant-subtle--_yeti-variant-soft--_yeti-variant-strong--_yeti-variant-text
- The label's for must match the control's id; the validator checks it. Reference the hint and the error from the control with aria-describedby so both are announced. Use aria-invalid="true" for errors found on the server. The required marker is decoration; the required attribute is what assistive tech reads. A switch is a checkbox with role="switch"; its label reads as the switch's name. A range needs a label like any control, and aria-valuetext when the numbers are not what a person would say. With validate.js loaded, the same aria-invalid is set from the browser's own check when a submit is refused, and the browser's message is written into an empty [data-error].
- Used without guards: :has(), :user-invalid, appearance: none, lh unit
- Behind
@supports: nothing
Optional enhancement: components/field/range.js. The component works without it.
Optional enhancement: components/field/validate.js. The component works without it.
Each event bubbles, crosses a shadow boundary, and cannot be cancelled.
| Event | Module | Detail | Description |
|---|---|---|---|
yeti:invalid |
validate.js |
{ controls } |
Dispatched on the form when a submit is refused, carrying the invalid controls in document order. |
Available since 7.0.0.