Styling Form Controls with CSS UI State Pseudo-Classes

UI state selectors like :checked, :disabled, and :user-invalid style form controls based on their interactive state without JavaScript.

Stop wiring up blur, input, and change listeners just to paint an error border on a form field. CSS UI state selectors style that state directly, in the stylesheet, with zero JavaScript. The selectors live in CSS Basic User Interface Module Level 4 (CSS UI 4) and cover every interactive state a field can be in: checked, unavailable, mandatory, valid, invalid, in-range, out-of-range, and the user-interaction variants that fix the worst UX mistake. Each pseudo-class carries specificity (0,1,0), one class-level selector, no more. That means a rule like .field:invalid { border-color: red; } beats a bare element selector but loses to any class or ID you add later. The trade-off is deliberate: UI state styling stays low in the cascade so your layout classes have the final say.

:checked, Custom Toggles Without a Single Event Listener

The :checked pseudo-class targets a checkbox or radio input that is currently selected. Its classic job is styling the input itself, but the real power comes from pairing it with the adjacent sibling combinator (+) to reach a sibling label or span. That combination builds a toggle switch, a segmented control, or an accordion without JavaScript toggling a class on change events. The input stays visually hidden with position: absolute; opacity: 0;, and the sibling carries the visual.

Building A Toggle Switch

Here is a complete toggle that ships in any browser shipped since 2015:

.toggle-input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.toggle-label {
  display: inline-block;
  width: 48px;
  height: 24px;
  border-radius: 12px;
  background: #ccc;
  transition: background 0.2s;
  cursor: pointer;
}

.toggle-label::after {
  content: "";
  display: block;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: white;
  margin: 2px;
  transition: transform 0.2s;
}

.toggle-input:checked + .toggle-label {
  background: #0077cc;
}

.toggle-input:checked + .toggle-label::after {
  transform: translateX(24px);
}
<input type="checkbox" id="dark" class="toggle-input">
<label for="dark" class="toggle-label" aria-hidden="true"></label>
<label for="dark">Dark mode</label>

The first label carries the visual switch; the second label carries the text. The :checked rule flips the background and slides the thumb. No change listener. No class list manipulation. No re-render. The state lives in the input’s checkedness, and the selector reads it directly.

Radio Groups And Tab Interfaces

For radio groups, the same pattern works with :checked on each radio, letting you build tab-like interfaces where one selection hides or shows content via subsequent sibling selectors.

:disabled, Styling the Unavailable, and Its Opposite :enabled

:disabled targets any form field that carries the disabled attribute, whether it is a button, input, select, or textarea. :enabled targets every field that is not disabled, which includes all fields without the attribute. The specificity stays (0,1,0) for both. Style unavailable fields with reduced opacity, a grey background, or a not-allowed cursor to signal the field is inert. Use :enabled to target interactive fields when you need to differentiate them from their unavailable siblings without relying on attribute selectors.

The Readonly Distinction

One caveat: :read-only and :read-write interact with the disabled state differently across engines. :read-only targets disabled inputs in some older implementations, a spec change resolved in current browsers, but do not assume :disabled is equivalent to :read-only. They are separate axes of state. The disabled axis controls interactivity; the readonly axis controls editability. A field can be both disabled and readonly, and :read-write targets non-readonly, non-disabled inputs as well as contenteditable elements.

:required and :optional, Marking the Mandatory

:required targets any input, select, or textarea that carries the required attribute. :optional targets fields that lack it. Both are widely available since Baseline 2015, shipped by Chrome 10, Firefox 4, Safari 5, and Edge 12. Use :required to add a visual asterisk via ::before on a label, or to style the field’s border with a subtle cue that it is mandatory. :optional lets you de-emphasise non-essential fields, reducing visual noise on long forms.

Combining With Validation States

A field can be required and valid, required and invalid, optional and valid, optional and invalid. These selectors combine with the validation ones freely. Do not use :required to infer whether the user has filled the field; that is the job of :valid and :invalid, which evaluate the current value against the field’s constraints. A required empty text input is invalid; an optional empty input is valid. That distinction is the whole game.

CSS Style Form Validation, :valid, :invalid, :in-range, :out-of-range

This section replaces the JavaScript Constraint Validation API (checkValidity()) toggling classes. :valid targets a field whose current value satisfies all its constraints: type, pattern, required, min, max, step. :invalid targets a field whose value violates any of them. :in-range and :out-of-range apply to numeric inputs with min and max attributes, giving you a finer axis than validity alone. All four are Baseline widely available since 2015.

The Page-Load Problem

The mistake every tutorial makes: applying :invalid styles on page load. An empty required field is invalid from the moment the page renders, so your form shows red borders everywhere before the user has touched a thing. That is poor UX. CSS form validation done right waits for user interaction, and that is exactly what :user-invalid exists to solve.

Immediate Feedback For Dialogs

Here is the immediate-feedback version, which you should only ship if the form lives inside a dialog that opens on demand and starts empty:

input:invalid {
  border-color: #c00;
}

input:valid {
  border-color: #0a0;
}

input[type="number"]:out-of-range {
  background: #fdd;
}

input[type="number"]:in-range {
  background: #dfd;
}

The :out-of-range selector fires only when the value sits outside the min/max bounds; an empty field is not out of range, it is invalid if required. These selectors work across all modern browsers, but the instant-red-on-load problem remains. That is not a bug in the selector; it is a misuse. The spec gives you the blunt instrument. The user-interaction counterpart is the refined one.

:user-invalid CSS Example, Error Styling That Respects the User

The :user-invalid pseudo-class replaces the old JavaScript pattern of listening for blur and change events to toggle a .touched or .dirty class. It targets a field only after the user has actually interacted with it: after they attempt to submit, or after they change the value and then leave the field. It does not fire on page load, so an untouched required field stays neutral. Firefox shipped :user-invalid in version 88 (April 2021), Chrome and Edge in version 119 (October 2023), and Safari in version 16.5 (May 2023). Its Baseline status is newly available as of 2023, meaning older browsers will not recognise it, but the fallback is straightforward.

The Guarded Implementation

Here is the full :user-invalid CSS example with the @supports guard:

@supports selector(:user-invalid) {
  input:user-invalid {
    border-color: #c00;
    box-shadow: 0 0 0 2px rgba(204, 0, 0, 0.2);
  }

  input:user-valid {
    border-color: #0a0;
  }
}

@supports not selector(:user-invalid) {
  input:invalid {
    border-color: #c00;
  }
}

In browsers without :user-invalid, the fallback to :invalid shows errors on load, exactly the behaviour you wanted to avoid. The accepted compromise: use @supports selector(:user-invalid) to apply the refined behaviour where available, and in older engines either accept the immediate error or keep a tiny JavaScript touch-detection script as a progressive enhancement. The :user-valid counterpart works symmetrically, marking a field green only after the user has interacted and the value is correct. This pair is the single most useful addition to form styling in the last decade.

:indeterminate, The Neither-Nor State of Checkboxes and Radios

:indeterminate targets a checkbox that is neither checked nor unchecked, the visual dash you see in a tri-state checkbox, and a radio group where no option is selected. It also targets a progress element with no value attribute. This state cannot be set via HTML alone for checkboxes; you must set it through JavaScript (input.indeterminate = true), but the styling is pure CSS. Use it to show a muted or half-filled visual for a checkbox that represents “some but not all” children in a tree. The specificity is (0,1,0), same as its siblings. For radio groups, an empty selection is the indeterminate state until the user picks one, which you can style with a dashed border or reduced opacity to signal “nothing chosen yet”. JavaScript sets a property, but the CSS selector reads it declaratively. No class toggling. No state duplication.

:read-only and :read-write, Beyond the [readonly] Attribute

:read-only targets elements that are not editable, including inputs with the readonly attribute, disabled inputs (in some engines, per spec history), and non-form elements like div or p. :read-write targets editable elements, which includes contenteditable regions and all inputs without readonly. The older technique used the attribute selector [readonly] to style read-only fields, but that misses the inverse and ignores contenteditable. Use :read-write to style editable areas specifically, like giving a contenteditable composer a focus-friendly border. The pair is widely available since Baseline 2015.

Watch The Select Element

One trap: :read-only does not target select elements that are not editable by choice. A native select is always read-write unless disabled. Do not swap [readonly] for :read-only blindly; test the exact element. These selectors are part of CSS UI 4 and live alongside the others listed here, giving you a complete vocabulary for every form state.

FAQ, Four Answers You Actually Need

Does :user-invalid work in Firefox? Yes. Firefox has supported it since version 88 (April 2021), well before Chrome and Safari. The Gecko engine shipped it earlier than Blink or WebKit, so Firefox is a safe target.

Can I use :disabled to style a fieldset that contains disabled inputs? No. :disabled targets the field itself, not the fieldset. A fieldset with the disabled attribute disables all descendant inputs, but the fieldset element does not match :disabled. Style it with fieldset[disabled] instead.

Why does my :invalid rule fire on page load? Because a required empty input is invalid by definition. :invalid targets the state, not the user’s intent. Use :user-invalid to delay the styling until interaction, or start the form with values pre-filled in tests.

What is the specificity of :checked compared to a class? They are equal: both (0,1,0). If you write .checkbox:checked, the specificity becomes (0,2,0). A later class rule can override the pseudo-class alone, but not the compound selector.

The Honest Caveat: Don’t Style Validation Alone

These selectors solve the styling half of form UX. They do not replace the need for accessible error messages. A red border is meaningless to a screen reader user, and color alone fails WCAG 1.4.1. Pair :user-invalid styling with aria-describedby pointing to a live region that announces the error. Set aria-invalid via JavaScript; it is an attribute, not a style. The CSS UI state selectors give you the visual, but the accessibility tree needs text. That is the line: JavaScript remains necessary for announcing errors, not for showing them. The visual layer is now fully declarative.