Form field states

ui.css · .form-fields · Cross-system: Cross-system reference § Forms

Overview

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 vocabulary

StateSet onMechanismVisual
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.

Disabled, readonly and locked

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:

ControlPair locked withWhat 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.

Decoration alone does not dim — opacity: 1. Its border brightens under the pointer.

Selectable and copyable, not editable — opacity: 0.85. Its border does not answer the pointer.

Out of service, dropped from the tab order — opacity: 0.6. Its border does not answer the pointer either.

Disabled wins — opacity: 0.6.

Stated, not offered — it lines up with the fields above.

Show code

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.

Show code

Which fields Tab reaches

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.

StateTab stopWhy
readonlyYesThe value can be selected and copied.
disabledNoNative behaviour. The control is out of service, so there is nothing to read or copy.
data-state="locked"YesIts pointer-events: none sits on the children and removes no Tab stop.
data-state="updating", aria-busyYesSame 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.

Editable — the ring stops here.

Read-only, and still a stop. This is the recommended shape.

Disabled — the ring skips it, and nothing you do brings it back.

Locked and read-only — a stop, because the name is still worth copying.

Read-only and opted out with tabindex="-1" — the ring skips it.

Show code

Which state wins

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:

  1. :hover
  2. focus ring (:has(:focus-visible))
  3. valid — .modified + :user-valid
  4. user-invalid — native :user-invalid
  5. flux — data-state="flux"
  6. updating / busy — data-state="updating", aria-busy="true"
  7. error — 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).

Validation feedback below the field

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.

We'll only use this for invoice receipts.

Show code

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.

Info and warning lines

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.

LineClassUse it whenValue accepted
hint.field-helpthe user needs to know what to enteryes
info.field-help.infothe line reports a result worked out from the valueyes
warning.field-help.warningthe value is allowed but unusualyes
success.field-successa check confirmed the valueyes
error.field-error and aria-invalid="true"the value is rejectedno

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.

Applied to every item in the order.

250 including 25% VAT.

More than half off. Check that this is intended.

Show code

In the demo below, the lines follow the values as you type. Raise the discount above 50 to see the warning replace the hint.

250 including 25% VAT.

Applied to every item in the order.

Show code

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.

Asking the server: hint, busy, verdict

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.

  1. Hint. The shared <small class="field-help"> explains what the lookup will check.
  2. Loading. 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.
  3. Result. Reuse the hint's <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.

Looked up when you leave the field.

Show code

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.

Dirty tracking with .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.

Type something new → border goes green. Revert to "Ada" → neutral again.

Show code

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.

Wrapping <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.

Show code

For an async-source <ui-combobox raw> inside an .input-group with a flux / updating suffix indicator, see the combobox suffix-indicator demo.

Accessibility

See also