| |

Tailwind CSS 31 🎨 State Variants (disabled:, checked:, required:, valid:, invalid:)

A form element has states that are not driven by the pointer. A disabled button cannot be clicked. A checked checkbox is selected. A required input must be filled. A valid input passes validation. An invalid input fails it. These states are part of the element, not of the interaction, and Tailwind exposes them as variants. The variant applies a utility only when the element is in that state, and the browser handles the state detection. No JavaScript is required.

This chapter covers the state variants in full. You will learn the form-state variants—disabled:, enabled:, checked:, indeterminate:, required:, optional:, valid:, invalid:, in-range:, out-of-range:, placeholder-shown:, and autofill:—the browser support for each, and the patterns that use them for accessible, styled forms.

Key point: A state variant applies a utility only when the element matches the pseudo-class. disabled:opacity-50 dims the element only when it is disabled. invalid:border-red-500 colors the border only when the input fails validation. The variant is the prefix, and the browser’s pseudo-class is the condition. No JavaScript is involved.


Why state variants matter

The form-styling problem. A form input has many states: empty, filled, focused, invalid, disabled, read-only. Styling each one requires a separate CSS rule with a pseudo-class. Tailwind’s variants put the state and the style together in one class, and the state is the browser’s pseudo-class, not a JavaScript variable.

The accessibility problem. A disabled element should look disabled. An invalid field should look invalid. An autofilled field should look autofilled. The visual feedback is part of the accessibility: the user needs to know the state of the element without reading a message. The state variants make the visual feedback automatic.

The validation problem. The :valid and :invalid pseudo-classes are applied by the browser based on the input’s constraints. An input with type="email" and required is :valid when the value is a valid email and :invalid when it is not. The valid: and invalid: variants style the two states. The validation is the browser’s, not the application’s, which means it works before any JavaScript runs.

The JavaScript problem. Before the state variants, styling a disabled input required a conditional class in the render. className={isDisabled ? 'opacity-50' : ''}. The state variant removes the conditional. The disabled:opacity-50 class is always present, and the browser applies it only when the input is disabled.

The consistency problem. The browser’s pseudo-classes are the same in every application. The :disabled state means the same thing everywhere. The variant makes the styling consistent with the state, and the state is the standard.

The Tailwind v4 problem. Tailwind v4 added new variants for states that were not covered in v3. The inert: variant, the user-valid: and user-invalid: variants, and the open: variant for <details> and <dialog> are part of the expanded state support. The existing variants—disabled, checked, required, valid, invalid—are unchanged.


a. The disabled and enabled variants

The disabled: variant applies when the element has the disabled attribute. The enabled: variant applies when it does not.

<button class="bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" disabled>
  Save
</button>

The button has a blue background by default. When it is disabled, the opacity is 50% and the cursor is not-allowed. The two utilities apply only in the disabled state.

<input class="border disabled:bg-gray-100 disabled:text-gray-500" disabled />

The input has a gray background and gray text when it is disabled. The visual difference tells the user that the field cannot be edited.

The enabled: variant is the inverse. It applies when the element is not disabled.

<button class="bg-gray-300 enabled:bg-blue-500 enabled:hover:bg-blue-600" disabled>
  Save
</button>

The button is gray by default. When it is enabled, it is blue, and when it is enabled and hovered, it is a darker blue. The enabled: variant makes the default gray and the enabled blue.

The disabled attribute is set on the element. In React, the disabled prop sets it. In plain HTML, the attribute is written directly.

<button disabled={isSubmitting}>Save</button>

The disabled: variant applies when isSubmitting is true, because the attribute is present.

The group-disabled: and peer-disabled: variants apply when a parent or sibling is disabled. This is useful for styling a label or an icon next to a disabled input.

<fieldset disabled>
  <input class="peer" />
  <span class="peer-disabled:opacity-50">Hint</span>
</fieldset>

The peer-disabled: applies to the sibling when the input is disabled. The fieldset disabled disables all the inputs inside it.


b. The checked, indeterminate, and required variants

The checked: variant applies when a checkbox or radio is checked.

<input type="checkbox" class="checked:bg-blue-600 checked:border-blue-600" />

The checkbox has a blue background and border when it is checked. The checked: variant works with the :checked pseudo-class.

The indeterminate: variant applies when a checkbox has the indeterminate property set. The property is set with JavaScript, not with an HTML attribute.

function SelectAll({ items, selected, onToggle }) {
  const ref = useRef(null);
  const allSelected = selected.length === items.length;
  const someSelected = selected.length > 0 && !allSelected;

  useEffect(() => {
    if (ref.current) {
      ref.current.indeterminate = someSelected;
    }
  }, [someSelected]);

  return (
    <input
      ref={ref}
      type="checkbox"
      className="indeterminate:bg-blue-300"
      checked={allSelected}
      onChange={onToggle}
    />
  );
}

The checkbox is indeterminate when some but not all items are selected. The indeterminate:bg-blue-300 styles it differently from the fully checked and fully unchecked states.

The required: variant applies when the element has the required attribute.

<input class="required:border-red-500" required />

The input has a red border when it is required. This is useful for indicating that a field is mandatory.

The optional: variant is the inverse. It applies when the element is not required.

<input class="optional:border-gray-300" />

The input has a gray border when it is not required.

The placeholder-shown: variant applies when the input is showing its placeholder, which is when the input is empty and has a placeholder.

<input class="placeholder-shown:italic placeholder="Type here" />

The input is italic when the placeholder is shown. This is often paired with a floating label pattern where the label moves up when the input has a value.

<div class="relative">
  <input class="peer placeholder-transparent" placeholder="Email" />
  <label class="absolute peer-placeholder-shown:top-2 peer-focus:-top-3">
    Email
  </label>
</div>

The label moves from the input’s position to above it when the input is not showing the placeholder. The peer-placeholder-shown: and peer-focus: variants control the position.


c. The valid, invalid, and range variants

The valid: variant applies when the element passes its validation constraints. The invalid: variant applies when it fails.

<input type="email" class="border invalid:border-red-500 valid:border-green-500" />

The input has a red border when the value is not a valid email and a green border when it is. The validation is the browser’s, based on the type and the constraints.

An email input with no value is :invalid if it has the required attribute, and neither :valid nor :invalid if it does not. The :valid and :invalid pseudo-classes apply only when the value has been entered or the field is required.

<input type="email" required class="invalid:border-red-500" />

The input is :invalid when the value is empty (because it is required) or when the value is not a valid email. The invalid:border-red-500 styles it red in both cases.

Tailwind v4 added the user-valid: and user-invalid: variants. These apply only after the user has interacted with the field, which avoids showing the error before the user has typed anything.

<input type="email" required class="user-invalid:border-red-500" />

The user-invalid: applies after the user has typed and blurred the field, if the value is invalid. This is the better pattern for error states because it does not flag an empty required field as invalid before the user has had a chance to fill it.

The in-range: and out-of-range: variants apply to inputs with min and max attributes.

<input type="number" min="1" max="10" class="out-of-range:border-red-500" />

The input has a red border when the value is outside the range. The in-range: variant is the inverse.

The autofill: variant applies when the browser has autofilled the input.

<input class="autofill:bg-yellow-100" />

The input has a yellow background when the browser autofills it. The default autofill styling is browser-specific, and this variant overrides it.

The read-only: variant applies when the element has the readonly attribute.

<input class="read-only:bg-gray-100" readonly />

The input has a gray background when it is read-only. The read-only: variant is distinct from disabled:: a read-only input can be focused and its value can be copied, but it cannot be edited.


Complete Example Session

<!-- ============================================ -->
<!-- PART 1: DISABLED BUTTON -->
<!-- ============================================ -->
<button class="bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" disabled>
  Save
</button>
<!-- ============================================ -->
<!-- PART 2: DISABLED INPUT -->
<!-- ============================================ -->
<input class="border disabled:bg-gray-100 disabled:text-gray-500" disabled />
<!-- ============================================ -->
<!-- PART 3: ENABLED VARIANT -->
<!-- ============================================ -->
<button class="bg-gray-300 enabled:bg-blue-500 enabled:hover:bg-blue-600" disabled>
  Save
</button>
<!-- ============================================ -->
<!-- PART 4: CHECKED CHECKBOX -->
<!-- ============================================ -->
<input type="checkbox" class="checked:bg-blue-600 checked:border-blue-600" />
<!-- ============================================ -->
<!-- PART 5: INDETERMINATE CHECKBOX -->
<!-- ============================================ -->
<input type="checkbox" class="indeterminate:bg-blue-300" />
<!-- ============================================ -->
<!-- PART 6: REQUIRED AND OPTIONAL -->
<!-- ============================================ -->
<input class="required:border-red-500" required />
<input class="optional:border-gray-300" />
<!-- ============================================ -->
<!-- PART 7: PLACEHOLDER SHOWN -->
<!-- ============================================ -->
<input class="placeholder-shown:italic" placeholder="Type here" />
<!-- ============================================ -->
<!-- PART 8: VALID AND INVALID -->
<!-- ============================================ -->
<input type="email" class="border invalid:border-red-500 valid:border-green-500" />
<!-- ============================================ -->
<!-- PART 9: USER-VALID AND USER-INVALID -->
<!-- ============================================ -->
<input type="email" required class="user-invalid:border-red-500" />
<!-- ============================================ -->
<!-- PART 10: FLOATING LABEL -->
<!-- ============================================ -->
<div class="relative">
  <input class="peer placeholder-transparent border rounded px-3 py-2" placeholder="Email" />
  <label class="absolute left-3 top-2 transition-all peer-placeholder-shown:top-2 peer-placeholder-shown:text-gray-400 peer-focus:-top-3 peer-focus:text-blue-500">
    Email
  </label>
</div>

The ten parts covered the disabled button, the disabled input, the enabled variant, the checked checkbox, the indeterminate checkbox, required and optional, placeholder-shown, valid and invalid, user-valid and user-invalid, and the floating label.


Quick Reference

Form State Variants

VariantApplies When
disabled:Element is disabled
enabled:Element is not disabled
checked:Checkbox or radio is checked
indeterminate:Checkbox is indeterminate
required:Element is required
optional:Element is not required
valid:Element passes validation
invalid:Element fails validation
user-valid:Valid after user interaction (v4)
user-invalid:Invalid after user interaction (v4)
in-range:Value within min/max
out-of-range:Value outside min/max
placeholder-shown:Placeholder is visible
autofill:Browser autofilled the input
read-only:Element is read-only

Peer and Group Variants

VariantApplies When
peer-disabled:Sibling is disabled
peer-checked:Sibling is checked
peer-invalid:Sibling is invalid
peer-placeholder-shown:Sibling shows placeholder
group-disabled:Parent group is disabled

Validation Attributes

AttributeConstraint
requiredMust be filled
type="email"Valid email
type="url"Valid URL
min / maxNumeric range
minlength / maxlengthLength range
patternRegular expression

Browser Support

VariantSupport
disabledAll
checkedAll
requiredAll
valid / invalidAll modern
user-valid / user-invalidModern browsers
autofillChrome, Firefox, Safari
indeterminateAll

Best Practices

✅ Do This:

<!-- Style disabled controls -->
<button class="disabled:opacity-50 disabled:cursor-not-allowed">       <!-- ✅ -->
</button>

<!-- Style invalid inputs -->
<input class="invalid:border-red-500" required />                      <!-- ✅ -->

<!-- Use user-invalid for errors after interaction -->
<input class="user-invalid:border-red-500" required />                 <!-- ✅ -->

<!-- Use placeholder-shown for floating labels -->
<input class="peer placeholder-transparent" />                         <!-- ✅ -->

<!-- Style checkboxes -->
<input type="checkbox" class="checked:bg-blue-600" />                  <!-- ✅ -->

<!-- Style required fields -->
<input class="required:border-red-500" required />                     <!-- ✅ -->

❌ Don’t Do This:

<!-- Don't rely on color alone -->
<input class="invalid:border-red-500" />                               <!-- ⚠️ -->

<!-- Don't use invalid before the user has typed -->
<input class="invalid:border-red-500" required />  <!-- flags empty --> <!-- ⚠️ -->

<!-- Don't forget the disabled attribute -->
<button class="disabled:opacity-50">Save</button>  <!-- no state -->    <!-- ❌ -->

<!-- Don't confuse readonly with disabled -->
<input class="disabled:bg-gray-100" readonly />   <!-- readonly not disabled --><!-- ⚠️ -->

<!-- Don't forget accessibility for the error -->
<input class="invalid:border-red-500" />  <!-- no aria or message -->  <!-- ⚠️ -->

<!-- Don't use checked on non-checkbox elements -->
<div class="checked:bg-blue-600">Not a checkbox</div>                  <!-- ❌ -->

Common Pitfalls

PitfallWhy It HappensFix
Disabled style not appliedMissing disabled attributeAdd the attribute
Invalid shows immediatelyEmpty required fieldUse user-invalid
Checked style ignoredElement is not a checkboxUse on checkbox/radio
Floating label brokenMissing peer classAdd peer to the input
Autofill style inconsistentBrowser defaultUse autofill:
Readonly treated as disabledDifferent attributesUse read-only:
Color-only error stateNo messageAdd text or icon

Real-World Examples

1. Disabled Button

<button class="bg-blue-500 disabled:opacity-50" disabled>Save</button>

2. Invalid Email

<input type="email" class="border invalid:border-red-500" required />

3. Error After Interaction

<input type="email" class="user-invalid:border-red-500" required />

4. Checked Checkbox

<input type="checkbox" class="checked:bg-blue-600" />

5. Floating Label

<div class="relative">
  <input class="peer placeholder-transparent" placeholder="Email" />
  <label class="absolute peer-placeholder-shown:top-2 peer-focus:-top-3">Email</label>
</div>

6. Required Indicator

<input class="required:border-red-500" required />

7. Number Range

<input type="number" min="1" max="10" class="out-of-range:border-red-500" />

8. Read-Only Field

<input class="read-only:bg-gray-100" readonly value="Read only" />

9. Autofill Style

<input class="autofill:bg-yellow-100" />

10. Peer Disabled Hint

<fieldset disabled>
  <input class="peer" />
  <span class="peer-disabled:opacity-50">Hint</span>
</fieldset>

Visual

Form State Variants

┌─────────────────────────────────────────────────────────────┐
│  <input class="border                                        │
│                disabled:bg-gray-100                          │
│                invalid:border-red-500                        │
│                valid:border-green-500                        │
│                required:border-red-300"                      │
│         required />                                          │
│                                                             │
│  Default:  gray border                                       │
│  Required: red-300 border                                    │
│  Invalid:  red-500 border                                    │
│  Valid:    green-500 border                                  │
│  Disabled: gray background                                   │
│                                                             │
│  The browser applies the variant based on the state.        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Checked vs Indeterminate

┌─────────────────────────────────────────────────────────────┐
│  UNCHECKED                                                  │
│  ☐  no background                                           │
│                                                             │
│  CHECKED                                                    │
│  ☑  checked:bg-blue-600                                     │
│                                                             │
│  INDETERMINATE                                              │
│  ▣  indeterminate:bg-blue-300                               │
│                                                             │
│  The three states are distinct.                             │
│  Indeterminate is set with JavaScript.                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

invalid vs user-invalid

┌─────────────────────────────────────────────────────────────┐
│  invalid:                                                   │
│                                                             │
│  Empty required field: INVALID                              │
│  The border is red before the user has typed.               │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  [           ]  ← red border, empty                 │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  user-invalid:                                              │
│                                                             │
│  Empty required field: NOT INVALID                          │
│  After typing and blurring: INVALID                         │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  [           ]  ← neutral border                    │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  The user-invalid variant waits for interaction.            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Floating Label

┌─────────────────────────────────────────────────────────────┐
│  EMPTY (placeholder shown)                                  │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  ┌─────────────────────────────────────────────┐    │    │
│  │  │  Email                                      │    │    │
│  │  └─────────────────────────────────────────────┘    │    │
│  │  Label is inside the input.                         │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  FILLED OR FOCUSED                                          │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  Email                                              │    │
│  │  ┌─────────────────────────────────────────────┐    │    │
│  │  │  user@example.com                           │    │    │
│  │  └─────────────────────────────────────────────┘    │    │
│  │  Label has moved above the input.                   │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  peer-placeholder-shown and peer-focus control the move.    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
disabled:Element is disabled
enabled:Element is not disabled
checked:Checkbox or radio is checked
indeterminate:Checkbox is indeterminate
required:Element is required
optional:Element is not required
valid:Element passes validation
invalid:Element fails validation
user-valid:Valid after interaction
user-invalid:Invalid after interaction
in-range:Value within min/max
out-of-range:Value outside min/max
placeholder-shown:Placeholder is visible
autofill:Browser autofilled
read-only:Element is read-only

Key takeaways:

  • State variants apply a utility when the element matches a pseudo-class. The browser handles the state detection; Tailwind provides the styling. No JavaScript is required for the state to be applied.
  • disabled: and enabled: are inverses. disabled: applies when the element has the disabled attribute. enabled: applies when it does not. Use them to style the two states differently.
  • checked: and indeterminate: are for checkboxes and radios. The checked pseudo-class is set by the browser when the element is checked. The indeterminate property is set with JavaScript, and the variant styles the indeterminate state.
  • valid: and invalid: are the browser’s validation. The pseudo-classes are applied based on the element’s constraints: type, required, min, max, pattern. The validation happens before any JavaScript runs.
  • user-valid: and user-invalid: wait for interaction. The invalid: variant flags an empty required field as invalid before the user has typed. The user-invalid: variant waits until the user has interacted, which is the better pattern for error messages.
  • placeholder-shown: enables the floating label. The variant applies when the input is empty and showing its placeholder. Combined with peer-focus:, it moves the label up when the input is focused.
  • The peer-* variants style siblings based on the input’s state. peer-disabled:, peer-checked:, peer-invalid:, and peer-placeholder-shown: apply to elements that follow the input in the DOM.

Remember: The state variants are the browser’s pseudo-classes exposed as Tailwind prefixes. They make the form’s states visible without JavaScript, and they keep the styling consistent with the state. Use disabled: for disabled controls, checked: for checkboxes, invalid: and user-invalid: for validation, and placeholder-shown: for floating labels. Combine them with the peer-* variants to style the elements around the input. The form’s states are part of the interface, and the variants are how the interface reflects them.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!