Clean Markup

Polite Form Validation UX with CSS user-valid and user-invalid

Polite Form Validation UX with CSS user-valid and user-invalid

Native browser form validation has long suffered from an aggressive visual user experience flaw. When developers style inputs using the standard :invalid and :valid pseudo-classes, every required field illuminates with alarming red warning borders the very second the page renders. Berating a user with error highlights before they have even placed a cursor inside the input field creates cognitive fatigue, damages trust, and historically forced teams to rebuild input state tracking with cumbersome JavaScript dirty and touched classes.

The native :user-valid and :user-invalid pseudo-classes solve this problem at the platform level. Supported across all modern browser engines, these pseudo-classes delay styling until the visitor has meaningfully interacted with the control—specifically when the user types and blurs the input, or attempts to submit the parent form. This creates an empathetic and polite validation workflow where error states only surface at appropriate stages of the user journey, requiring zero client-side scripts to govern input pristine states.

Example

/* Polite validation: Only styles inputs after the user interacts */
input:user-invalid {
  border-color: #dc2626;
  background-color: #fef2f2;
}

input:user-valid {
  border-color: #16a34a;
  background-color: #f0fdf4;
}

/* Ensure unmodified inputs remain neutral on initial render */
input:not(:user-invalid):not(:user-valid) {
  border-color: #cbd5e1;
}

Implementation

<!-- Fully accessible and politely validated checkout form -->
<form class="checkout-form" novalidate>
  <fieldset class="form-group">
    <legend class="form-title">Account Registration</legend>

    <!-- Email Field -->
    <div class="field-wrapper">
      <label for="user-email" class="field-label">
        Work Email Address <span aria-hidden="true">*</span>
      </label>
      <div class="input-container">
        <input
          id="user-email"
          name="email"
          type="email"
          class="form-input"
          required
          autocomplete="email"
          aria-describedby="email-hint email-error"
        />
        <span class="status-indicator" aria-hidden="true"></span>
      </div>
      <p id="email-hint" class="field-hint">We will send your verification code here.</p>
      <p id="email-error" class="field-error">Please enter a valid email address with an @ domain.</p>
    </div>

    <!-- Password Field with native length constraints -->
    <div class="field-wrapper">
      <label for="user-password" class="field-label">
        Security Password <span aria-hidden="true">*</span>
      </label>
      <div class="input-container">
        <input
          id="user-password"
          name="password"
          type="password"
          class="form-input"
          required
          minlength="8"
          pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}"
          autocomplete="new-password"
          aria-describedby="password-hint password-error"
        />
        <span class="status-indicator" aria-hidden="true"></span>
      </div>
      <p id="password-hint" class="field-hint">Must contain at least 8 characters, one uppercase, and one number.</p>
      <p id="password-error" class="field-error">Password does not satisfy complexity requirements.</p>
    </div>

    <button type="submit" class="submit-button">Create Account</button>
  </fieldset>
</form>

<style>
/* Layout and structural baseline */
.checkout-form {
  max-width: 28rem;
  margin: 1.5rem 0;
  font-family: inherit;
}

.form-group {
  border: 1px solid var(--border-color, #e2e8f0);
  border-radius: 8px;
  padding: 1.5rem;
  background-color: var(--surface-1, #ffffff);
}

.form-title {
  font-weight: 700;
  font-size: 1.25rem;
  padding: 0 0.5rem;
  color: var(--text-heading, #0f172a);
}

.field-wrapper {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  margin-bottom: 1.25rem;
}

.field-label {
  font-size: 0.9rem;
  font-weight: 600;
  color: var(--text-heading, #1e293b);
}

.input-container {
  position: relative;
  display: flex;
  align-items: center;
}

.form-input {
  width: 100%;
  padding: 0.65rem 2.25rem 0.65rem 0.75rem;
  font-size: 1rem;
  border: 1.5px solid var(--border-color, #cbd5e1);
  border-radius: 6px;
  outline: none;
  transition: border-color 0.2s ease, box-shadow 0.2s ease;
}

.form-input:focus-visible {
  border-color: #2563eb;
  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
}

/* User interaction-aware validation states */
.form-input:user-valid {
  border-color: #16a34a;
  background-color: #f0fdf4;
}

.form-input:user-valid:focus-visible {
  box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.2);
}

.form-input:user-invalid {
  border-color: #dc2626;
  background-color: #fef2f2;
}

.form-input:user-invalid:focus-visible {
  box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.2);
}

/* Status icon rendering via accessible non-color cues */
.status-indicator {
  position: absolute;
  right: 0.75rem;
  width: 1.2rem;
  height: 1.2rem;
  pointer-events: none;
  background-repeat: no-repeat;
  background-position: center;
  background-size: contain;
}

.form-input:user-valid + .status-indicator {
  /* Green checkmark SVG icon data URI */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2316a34a' stroke-width='3'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M5 13l4 4L19 7'/%3E%3C/svg%3E");
}

.form-input:user-invalid + .status-indicator {
  /* Red warning exclamation icon data URI */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23dc2626' stroke-width='2.5'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'/%3E%3C/svg%3E");
}

/* Helper texts and conditional message display */
.field-hint {
  font-size: 0.8rem;
  color: var(--text-muted, #64748b);
  margin: 0;
}

.field-error {
  display: none;
  font-size: 0.8rem;
  font-weight: 500;
  color: #dc2626;
  margin: 0;
}

/* Reveal error text solely when field is user-invalid */
.field-wrapper:has(.form-input:user-invalid) .field-error {
  display: block;
}

.field-wrapper:has(.form-input:user-invalid) .field-hint {
  display: none;
}

.submit-button {
  width: 100%;
  padding: 0.75rem 1.25rem;
  font-size: 1rem;
  font-weight: 600;
  color: #ffffff;
  background-color: #2563eb;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.submit-button:hover {
  background-color: #1d4ed8;
}
</style>

Main Point: The CSS user-valid and user-invalid pseudo-classes eliminate premature validation warnings by waiting until a user has meaningfully engaged with an input field before rendering feedback.

Credits

Photo by Victor Garcia on Unsplash

← Home