ui.css
· .form-fields
· Cross-system: Cross-system reference § Forms
A unified vocabulary for form-field state, spanning native
pseudo-classes and attributes (:disabled,
[readonly], :user-valid,
:user-invalid), ARIA attributes
(aria-invalid, aria-busy), and
data-state on the group
(flux, updating, locked,
error).
The line under the field has its own kinds: hint, info, warning,
success and error; see Info and warning lines.
All state coloring lives on .input-group. Wrap every
control that needs a state indicator — including
<x-select raw> and
<ui-combobox raw> — in an .input-group,
and the same border / focus ring / suffix-icon treatment applies.
:user-valid and :user-invalid apply after interaction or form validation through reportValidity(). This avoids showing errors before the user has had a chance to enter a value, without manually toggling classes.
| State | Set on | Mechanism | Visual |
|---|---|---|---|
disabled |
child | native disabled attr → .input-group:has(:disabled) |
group opacity: 0.6; cursor: not-allowed; no hover cue, and no Tab stop |
readonly |
child | native readonly attr → .input-group:has(:where(input, textarea)[readonly]) |
group opacity: 0.85; content selectable, not editable; no hover cue, but it keeps its Tab stop and focus ring. Also how a form shows a value with no control behind it |
user-invalid |
child | native :user-invalid |
red border, red .suffix.state icon — fires after user interaction or reportValidity() |
user-valid(opt-in via .modified) |
child + group | native :user-valid + .modified class on group |
Green border when unfocused and a green .suffix.state icon. Requires .modified, so restoring the initial value leaves the field neutral.
|
error |
group or child | data-state="error" on group, or aria-invalid="true" on child |
Red border without requiring prior interaction. Use for API or other asynchronous errors. |
flux |
group | data-state="flux" |
orange border; pending save / mutation |
updating |
group | data-state="updating" or aria-busy="true" |
muted border; cursor: progress; children don't accept pointer events. Use aria-busy when SR users should pause until the update completes. |
locked |
group | data-state="locked" |
Sets cursor: not-allowed, blocks pointer interaction with children and suppresses the hover cue, while retaining their Tab stops and focus ring. It does not dim the field or change the border color. It announces nothing and does not stop keyboard editing: the control carries both, through its own attribute. Disabled, readonly and locked says which attribute each kind of control takes.
|
Show success only for a changed value. A field can match :user-valid after the user types and then restores its original value. To avoid showing that as a saved change, toggle .modified on the group when el.value !== el.defaultValue. Error styling does not require .modified: an invalid value still needs an explanation when it matches the initial value.
Do not require .modified for invalid-state styling. A required field can return to its original empty value after the user edits it; it still needs an error indicator. The browser's :user-invalid pseudo-class already delays that indicator until the user has interacted with the field.
aria-invalid vs data-state="error".
Same visual; pick by semantics. aria-invalid="true" on the
input when the error is about field content the SR should hear;
data-state="error" on the group when the error is
group-level and you're already using other data-state
values.
These states describe whether a field can be edited, so they are separate from the state ladder for value-state border colors. disabled and readonly dim the group. data-state="locked" sets cursor: not-allowed and prevents pointer interactions with its children while preserving Tab access. It paints no colour of its own and does not dim.
locked is half a state, and the control carries the other half. A data- value reaches no accessibility tree, so data-state="locked" changes how the field looks and what the pointer can do, and changes nothing about what a screen reader says. On its own it produces a field that looks unavailable and announces itself as an ordinary editable one — a keyboard user Tabs in, hears "Owner, edit, Ada Lovelace", types, and nothing happens. That mismatch between what a control offers and what it reports is what WCAG SC 4.1.2 forbids. Put the announcement on the control, and use locked for the frame and the pointer guard over it.
Which attribute to use depends on the control, because readonly does not exist on all of them:
| Control | Pair locked with | What the user hears |
|---|---|---|
<input>, <textarea> |
readonly |
"read only". The field keeps its Tab stop, so the value can still be copied. |
native <select> |
disabled |
"unavailable". <select> has no readonly, so this is the only native answer, and the group then dims to opacity: 0.6 and leaves the tab order like any disabled group. |
<x-select>, <ui-combobox>, a control built from a <button> |
aria-disabled="true" |
"unavailable", while the Tab stop and the undimmed frame stay — which is what locked is for. aria-disabled only announces, so the control's own handler has to refuse activation; locked supplies the pointer half. |
Nothing checks that you wrote it. These pages and their demos are where the markup is specified, and the author writes it — see Conventions § "Plain HTML, and what a decorator is for". A forgotten pairing is caught by code review rather than at runtime.
Use the control's attributes to dim disabled and read-only fields. Avoid :read-only: it matches every element that is not editable, including decorations, buttons and input types that do not support readonly, such as checkboxes, radios, ranges, colors and files. The selector :has(:where(input, textarea)[readonly]) limits the styling to the intended fields. Decorations alone must leave a group's opacity at 1.
The rules have equal specificity, with disabled written second. A control that is both disabled and read-only therefore uses the disabled opacity of 0.6.
Use readonly for a value that has no control behind it at all — one the server derives, or one this caller may not set — and not only for an input whose editing is switched off. Keep it in an .input-group: the dimming rule is .input-group:has(:where(input, textarea)[readonly]), so a bare input outside a group is not dimmed. Presented this way the value takes the frame, width, padding and label alignment of every other field, and the form does not change shape between a caller who may edit it and one who may not. A badge or an unframed <span> loses all four: a pill stretched across the field's width reads as a disabled control, and an unframed span inherits the wrapper's label type size. Give the reason in the .field-help line below the field, which is where a reader looks for it, and point aria-describedby at it so a screen reader hears the reason on focus.
None of the three answers the pointer. An editable field brightens its border from --border-color to --border-strong under the pointer, which is how it says it is ready for typing. A field the caller cannot edit says the opposite in the same frame, so the hover rule excludes all three access states. The focus ring is a different mark with a different job: it reports where the keyboard is. readonly and locked fields are still Tab stops, so they keep the ring; a disabled field cannot be focused at all, so the question never arises for it. See Which fields Tab reaches.
This does not change any field's resting border, so it neither improves nor worsens border contrast. --border-color already sits below the 3:1 that WCAG 1.4.11 asks of a control boundary, for editable and read-only fields alike; raising it is a change to the shared token, not to these rules.
One row per line of the table above. Tab through them: the first two are stops and the third is not, and each one says why it cannot be changed through its own attribute rather than through data-state.
Leave a read-only field in the tab order. That is what readonly does on its own — unlike disabled, it keeps the element focusable — and it is the recommendation here. The value is selectable and copyable, and the Tab stop is the only way a keyboard user gets what a mouse user gets by dragging across the text. A screen reader does not need the stop to read the value; its browse cursor reaches any text on the page. The stop is for the sighted keyboard user who wants to copy what the field says.
No accessibility rule decides this. WCAG neither requires a read-only field to be a Tab stop nor forbids it: 2.4.3 constrains the order of focus, not which elements are in it, and 2.1.1 reaches the question only if you count selecting text as functionality. One rule is binding, and it is conditional: a field that can be focused must show where focus is (2.4.7, and 2.4.11 for the indicator's size and contrast). That is why the access states drop the hover cue and keep the focus ring instead of losing both together.
| State | Tab stop | Why |
|---|---|---|
readonly | Yes | The value can be selected and copied. |
disabled | No | Native behaviour. The control is out of service, so there is nothing to read or copy. |
data-state="locked" | Yes | Its pointer-events: none sits on the children and removes no Tab stop. |
data-state="updating", aria-busy | Yes | Same shape as locked, and the value is still worth copying while a save is in flight. |
Locked guards the pointer; the control's own attribute is what stops the keyboard. data-state="locked" blocks pointer interaction and changes the cursor, and that is all it does — a keyboard user who Tabs into a locked field can still type into it, and hears nothing to say they should not. Mark the control itself whenever the value must not change, and use locked for the look and the pointer guard on top of it. Disabled, readonly and locked gives the attribute per control: readonly on a text field, disabled on a native <select>, aria-disabled="true" on a composite control.
How to opt out. Set tabindex="-1" on the input. A long form that is mostly read-only for some callers costs those callers a keystroke per row, and that can outweigh the copying. Only do it where the value is also readable as ordinary text nearby, so nothing is reachable by pointer alone. Do not reach for disabled to get the same effect: it says the control is out of service rather than that the value is settled, and it takes the value out of the accessibility tree's editable-field vocabulary.
Press Tab from the first field below and watch where the ring lands. The button at the end is there so the walk finishes inside the demo rather than in the browser toolbar.
Several states can apply simultaneously. For example, a modified, valid field can receive a save rejection. Resolve these combinations using the following priority order, lowest first:
:hover:has(:focus-visible)).modified + :user-valid:user-invaliddata-state="flux"data-state="updating", aria-busy="true"aria-invalid="true", data-state="error"Explicit errors have the highest priority. A server rejection must remain visible during a retry and must override any earlier valid-state styling.
Native validation and explicit errors have different priorities. A partially entered value often matches :user-invalid; it should not hide the updating border during a save. Native validation therefore ranks just above success. Explicit errors, aria-invalid="true" and data-state="error", rank highest so a server rejection remains visible. Set aria-invalid for that rejection, rather than merely copying the browser's constraint-validation state.
locked, readonly and disabled control access rather than validity. They change opacity or the cursor while leaving border colors available to show the value's state, so they hold no rung of their own. What they do to the ladder is gate its lowest rung: a group in any of the three is excluded from :hover, and keeps the focus ring above it. See Disabled, readonly and locked and Which fields Tab reaches.
Success has the lowest priority because current activity and errors are more useful than an earlier valid result. While the group has focus, its green border becomes neutral to keep the focus ring clear. The .suffix.state icon can remain green because it does not compete with that ring.
In ui.css, rules have equal specificity and appear in priority order. Later rules win. Add new states at the appropriate position rather than raising a rule's weight with another class or a bare :not(); a heavier rule could otherwise override an error from below its rung. A filter that changes which elements a rung reaches, without changing its weight, is a different thing and is allowed: wrap it in :where(), which contributes nothing to specificity. The access-state exclusion on :hover and the chips carve-out on :user-invalid are both written that way, and both stay at (0,2,0).
A single feedback region sits under the input. It starts as a muted
help hint, swaps to a red error message when the user leaves an
invalid value, and to a green confirmation when the value becomes
valid. Native :user-invalid / :user-valid
drive the switch — the script flips aria-invalid, changes
the region's class, and writes the text.
Align feedback text with the field's text, label and .prefix icon. Only the field border extends farther left. Controls without a surrounding frame keep their normal alignment. See Forms § Anatomy.
One <small> serves help, error, and success — only
one applies at a time, so they share a slot.
aria-describedby points at it once; the SR reads whichever
text currently fits the field's state. aria-live="polite"
re-announces after the swap, without the cross-SR quirks of toggling
hidden on a role="alert" element.
Use the same feedback element for server errors, such as an address already in use. Set aria-invalid="true", apply field-error and write the explanation. This does not depend on :user-invalid. Asking the server demonstrates the complete hint, loading and result sequence.
If a field's options fail to load, disable it and explain the failure in the same styled <small> element. Do not set aria-invalid: no value was rejected. The field is unavailable, rather than invalid, so it does not use the error tier of the ladder. See Status messages § Form errors for this and other form-error locations.
The feedback line under a field says one of five things. A plain .field-help line tells the user what to enter. Add .info when the line reports something worked out from the current value, such as a total or a converted amount. Add .warning when the value is allowed but unusual and the user should look at it again. .field-error says the value is rejected, and .field-success confirms a checked value.
| Line | Class | Use it when | Value accepted |
|---|---|---|---|
| hint | .field-help | the user needs to know what to enter | yes |
| info | .field-help.info | the line reports a result worked out from the value | yes |
| warning | .field-help.warning | the value is allowed but unusual | yes |
| success | .field-success | a check confirmed the value | yes |
| error | .field-error and aria-invalid="true" | the value is rejected | no |
The line holds one message at a time. When several apply, show the highest: error, then warning, then info, then the hint. A warning outranks info because it asks the user to act. Neither info nor warning sets aria-invalid, and neither stops the form from submitting. The field's border keeps following the state vocabulary: a warning colours only the line, so it also works under a control that has no .input-group frame, such as a range slider.
In the demo below, the lines follow the values as you type. Raise the discount above 50 to see the warning replace the hint.
Change the text and the class together, in the same step, so a screen reader announces the new text once. The .warning variant means the same thing on a badge: allowed, but it needs attention.
The browser can validate format, but some values need a server check: an address must identify an account, or a name must still be available. Use the same feedback element for the initial hint, loading state and result, as demonstrated below.
<small class="field-help"> explains what the lookup will check.
with_load_feedback(group, promise) sets aria-busy="true" on the .input-group while awaiting the request and clears it on success or failure. This applies the updating tier border and cursor: progress. The 200 ms delay avoids flicker for quick responses.
<small>. For a rejection, apply .field-error and set aria-invalid="true" on the input. Keep the existing accessibility association; see Accessibility.
The server may reject a correctly formatted address because its account is already on the team, is deactivated or belongs to the caller. Native constraint validation cannot check these conditions, so enable submission only after an acceptable server response. Explain how to resolve a rejection so the user knows whether to choose another account or take a different action.
Apply this loading helper to the .input-group. Setting aria-busy on a .btn has accessibility effects but does not apply the group's visual styles. For visible button progress during submission, see Loading & progress.
Try these addresses in the demo: ada@example.com is accepted; grace@example.com is already a member; alan@example.com is deactivated; hopper@example.com has no account. Each rejection explains how to proceed. boom@example.com simulates a failed lookup. Other addresses are unknown. Leave the field with Tab, or press Enter, to start the lookup.
The <span class="suffix state"> inside the group confirms which account was found and inherits the group’s state color. The <small> below explains a rejection. Fill only the applicable element so each response produces one message.
Show progress through the border, cursor and disabled submit button. Keep the spinner aria-hidden="true": aria-busy="true" delays screen-reader updates while the lookup runs. Announce the result through the aria-live="polite" region, since the response arrives asynchronously without moving focus.
Give each lookup a sequence number and discard responses that are no longer current. Users may continue typing during a lookup; an older response must not display an error for an address they have already changed.
Use this pattern for feedback about an individual field. Report submission-wide failures, such as a failed request or lost connection, through the notification store. See Status messages: form errors.
.modified
Snapshot the initial value on load; on every input event,
toggle .modified on the group based on whether the
current value differs from the snapshot. When the group is
.modified and the input is :user-valid, the
border paints green. Revert to the initial value and the class comes
off — neutral again.
For forms with many fields, wrap the pattern in a helper that listens
on the form and diffs new FormData(form) against a
snapshot taken at load. The library provides the visual hook; the
state machine is app-level.
<x-select> / <ui-combobox>
Custom form controls render their own field chrome by default. Inside
an .input-group, add raw to strip the
control's chrome so the group owns the border, focus ring, and state
coloring. Dropdown / popover chrome stays with the control.
For an async-source <ui-combobox raw> inside an
.input-group with a flux / updating suffix indicator,
see the
combobox
suffix-indicator demo.
aria-describedby pointing to the shared feedback element. Its text and class change between help, error and success; update aria-invalid on the input when needed. aria-errormessage is exposed only while the input is invalid, so it cannot describe a region that also contains help text. Use it only for a separate error element alongside a permanent aria-describedby hint.
aria-live="polite". Update its text instead of toggling hidden on an alert, which screen readers handle inconsistently. An empty region occupies only the row gap. The live region is especially useful for disabled controls: they cannot receive focus, so focusing them cannot trigger their aria-describedby text. See Status messages: accessibility.
aria-busy pauses SR reading. Set on the
group while updating; clear when done. The debounce pattern in
<ui-outlet> and <ui-dialog> is
good prior art for sub-second updates.
.input-group, the
group owns the ring via :has(:focus-visible); the
child's own ring is suppressed. Applies to native inputs, textareas,
<x-select raw>, and
<ui-combobox raw>.
readonly and disabled map to the accessibility
tree on their own, and the library adds nothing. data-state
values do not, so a group carrying locked announces nothing
until the control inside it is marked too. Which attribute to use is per
control, since readonly does not exist on all of them — the
table in Disabled, readonly and locked.
aria-hidden="true". Meaningful prefix
text (e.g. "$") gets an id and is chained via
aria-labelledby="label-id prefix-id" on the input.
disabled control, including readonly,
locked and updating. Those states set
pointer-events: none on the children, not
tabindex="-1". Why that is the recommendation, what it
costs, and how to opt out of it are on
Which fields Tab reaches.
.form-fields — anatomy, input groups, the canonical patterns.<ui-combobox> — [raw] mode + async state indicators.<x-select> — customizable select, also supports [raw].toast() + <ui-statusbar>, not into the form.