8 min read
0%

CSS :user-valid and :user-invalid - Better Form Validation

Back to Blog
CSS :user-valid and :user-invalid - Better Form Validation

:user-valid and :user-invalid: Better Form Validation UX

Form validation has always been tricky in CSS. Show errors too early and you frustrate users. Show them too late and errors go unnoticed. The :user-valid and :user-invalid pseudo-classes solve this by tracking user interaction.

The Problem with :valid and :invalid

Traditional validation pseudo-classes trigger immediately:

input:invalid {
  border-color: red;
}

This creates a poor user experience:

<input type="email" required />
<!-- Shows red border IMMEDIATELY, before user types anything -->

The typical workaround was complex:

/* Only show validation after user has interacted */
input:invalid:not(:placeholder-shown):not(:focus) {
  border-color: red;
}

This is verbose, hard to remember, and doesn’t handle all cases properly.

Enter :user-valid and :user-invalid

These new pseudo-classes only trigger after the user has interacted with the field:

input:user-invalid {
  border-color: red;
}

input:user-valid {
  border-color: green;
}

The browser determines “interaction” as:

  • User has focused and blurred the field
  • User has typed something
  • Form has been submitted

Practical Use Cases

1. Basic Form Validation

/* No styles until user interacts */
input {
  border: 2px solid #ddd;
  padding: 0.75rem;
  border-radius: 4px;
  transition: border-color 0.2s;
}

/* Show error state after interaction */
input:user-invalid {
  border-color: hsl(0 100% 50%);
  background: hsl(0 100% 98%);
}

/* Show success state */
input:user-valid {
  border-color: hsl(120 100% 35%);
  background: hsl(120 100% 98%);
}

2. Validation Messages

.field-group {
  position: relative;
  margin-bottom: 1.5rem;
}

/* Hide error message by default */
.error-message {
  display: none;
  color: hsl(0 100% 45%);
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

/* Show error message when field is user-invalid */
input:user-invalid + .error-message {
  display: block;
}

/* Success checkmark */
input:user-valid::after {
  content: "✓";
  position: absolute;
  right: 1rem;
  color: hsl(120 100% 35%);
  font-weight: bold;
}

3. Progressive Enhancement

/* Base styles work in all browsers */
input:invalid:not(:placeholder-shown):not(:focus) {
  border-color: red;
}

/* Enhanced experience with :user-invalid */
@supports selector(:user-invalid) {
  /* Reset fallback */
  input:invalid:not(:placeholder-shown):not(:focus) {
    border-color: #ddd;
  }

  /* Use better pseudo-class */
  input:user-invalid {
    border-color: red;
  }
}

4. Form-Level Validation

/* Style the entire form based on validity */
form:has(:user-invalid) {
  border: 2px solid hsl(0 100% 50%);
  padding: 2rem;
  background: hsl(0 100% 98%);
}

/* Success state */
form:has(input:user-valid):not(:has(:user-invalid)) {
  border: 2px solid hsl(120 100% 35%);
  background: hsl(120 100% 98%);
}

/* Disable submit button when form has errors */
form:has(:user-invalid) button[type="submit"] {
  opacity: 0.5;
  cursor: not-allowed;
  pointer-events: none;
}

Advanced Techniques

Icon Indicators

.input-wrapper {
  position: relative;
}

.input-wrapper::after {
  content: "";
  position: absolute;
  right: 1rem;
  top: 50%;
  transform: translateY(-50%);
  width: 20px;
  height: 20px;
  background-size: contain;
  opacity: 0;
  transition: opacity 0.2s;
}

/* Error icon */
input:user-invalid ~ .input-wrapper::after {
  opacity: 1;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23dc3545'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z'/%3E%3C/svg%3E");
}

/* Success icon */
input:user-valid ~ .input-wrapper::after {
  opacity: 1;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23198754'%3E%3Cpath d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/%3E%3C/svg%3E");
}

Animated Validation

input {
  border: 2px solid #ddd;
  transition: all 0.3s ease;
}

input:user-invalid {
  border-color: hsl(0 100% 50%);
  animation: shake 0.3s ease;
}

@keyframes shake {
  0%,
  100% {
    transform: translateX(0);
  }
  25% {
    transform: translateX(-10px);
  }
  75% {
    transform: translateX(10px);
  }
}

input:user-valid {
  border-color: hsl(120 100% 35%);
  animation: success-pulse 0.5s ease;
}

@keyframes success-pulse {
  0%,
  100% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.02);
  }
}

Field-Specific Messages

/* Email validation */
input[type="email"]:user-invalid {
  border-color: hsl(0 100% 50%);
}

input[type="email"]:user-invalid + .hint {
  display: block;
  color: hsl(0 100% 45%);
}

input[type="email"]:user-invalid + .hint::before {
  content: "Please enter a valid email address";
}

/* Password validation */
input[type="password"]:user-invalid + .hint::before {
  content: "Password must be at least 8 characters";
}

/* Number validation */
input[type="number"]:user-invalid + .hint::before {
  content: "Please enter a valid number";
}

Real-World Example: Registration Form

.registration-form {
  max-width: 500px;
  margin: 0 auto;
  padding: 2rem;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.form-field {
  margin-bottom: 1.5rem;
}

.form-field label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 600;
  color: #333;
}

.form-field input {
  width: 100%;
  padding: 0.75rem 1rem;
  border: 2px solid #e0e0e0;
  border-radius: 6px;
  font-size: 1rem;
  transition: all 0.2s;
}

.form-field input:focus {
  outline: none;
  border-color: #0066cc;
  box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}

/* Invalid state - only after user interaction */
.form-field input:user-invalid {
  border-color: #dc3545;
  background-color: #fff5f5;
  padding-right: 3rem; /* Space for icon */
}

/* Valid state */
.form-field input:user-valid {
  border-color: #28a745;
  background-color: #f0fff4;
  padding-right: 3rem;
}

/* Error message */
.error-message {
  display: none;
  margin-top: 0.5rem;
  padding: 0.5rem;
  background: #fff5f5;
  border-left: 3px solid #dc3545;
  color: #dc3545;
  font-size: 0.875rem;
  border-radius: 4px;
}

.form-field:has(input:user-invalid) .error-message {
  display: block;
  animation: slideDown 0.2s ease;
}

@keyframes slideDown {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* Success checkmark */
.form-field {
  position: relative;
}

.form-field::after {
  content: "";
  position: absolute;
  right: 1rem;
  top: 2.75rem;
  width: 24px;
  height: 24px;
  opacity: 0;
  transition: opacity 0.2s;
}

.form-field:has(input:user-valid)::after {
  content: "✓";
  opacity: 1;
  color: #28a745;
  font-size: 1.25rem;
  font-weight: bold;
}

.form-field:has(input:user-invalid)::after {
  content: "✕";
  opacity: 1;
  color: #dc3545;
  font-size: 1.25rem;
  font-weight: bold;
}

/* Submit button state */
button[type="submit"] {
  width: 100%;
  padding: 1rem;
  background: #0066cc;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.2s;
}

button[type="submit"]:hover {
  background: #0052a3;
}

/* Disable when form has invalid fields */
.registration-form:has(input:user-invalid) button[type="submit"] {
  background: #ccc;
  cursor: not-allowed;
  opacity: 0.6;
}

Browser Support

:user-valid and :user-invalid have growing support:

  • Chrome/Edge: ✅ v119+ (November 2023)
  • Firefox: ✅ v88+ (April 2021) - with prefix -moz-ui-valid/-moz-ui-invalid
  • Safari: ❌ Not yet supported

Current global support is ~60%, requiring fallbacks for production.

Progressive Enhancement Strategy

/* Fallback for all browsers */
input:invalid:not(:focus):not(:placeholder-shown) {
  border-color: red;
}

input:valid:not(:placeholder-shown) {
  border-color: green;
}

/* Enhanced experience for supporting browsers */
@supports selector(:user-invalid) {
  /* Reset fallback styles */
  input:invalid:not(:focus):not(:placeholder-shown),
  input:valid:not(:placeholder-shown) {
    border-color: #ddd;
  }

  /* Use better pseudo-classes */
  input:user-invalid {
    border-color: red;
  }

  input:user-valid {
    border-color: green;
  }
}

/* Firefox with vendor prefix */
@supports selector(:-moz-ui-invalid) {
  input:-moz-ui-invalid {
    border-color: red;
  }

  input:-moz-ui-valid {
    border-color: green;
  }
}

Comparison with Other Approaches

Old Way: JavaScript

// ❌ JavaScript required
input.addEventListener("blur", () => {
  if (!input.validity.valid) {
    input.classList.add("is-invalid");
  }
});

Old Way: Complex CSS

/* ❌ Verbose and incomplete */
input:invalid:not(:placeholder-shown):not(:focus) {
  border-color: red;
}

New Way: :user-invalid

/* ✅ Simple and comprehensive */
input:user-invalid {
  border-color: red;
}

Accessibility Considerations

Always pair visual validation with proper ARIA attributes:

<div class="form-field">
  <label for="email">Email</label>
  <input
    type="email"
    id="email"
    required
    aria-describedby="email-error"
    aria-invalid="false"
  />
  <span id="email-error" class="error-message" role="alert">
    Please enter a valid email address
  </span>
</div>
// Update aria-invalid with JavaScript
input.addEventListener("blur", () => {
  input.setAttribute("aria-invalid", !input.validity.valid);
});

Common Pitfalls

1. Forgetting Required Attribute

/* Won't work without validation constraint */
input:user-invalid {
  border-color: red;
}
<!-- ✅ Add validation constraint -->
<input type="email" required />
<input type="text" pattern="[A-Za-z]+" />
<input type="number" min="0" max="100" />

2. Placeholder vs Default Value

<!-- :user-invalid won't trigger on page load -->
<input type="email" placeholder="Enter email" />

<!-- But will if there's a default value that's invalid -->
<input type="email" value="invalid-email" />

3. Form Submission

/* User-invalid doesn't trigger until user interacts OR form is submitted */
form:has(:user-invalid) {
  /* This might not show errors until submit is clicked */
}

Combining with Other Features

With :has()

/* Style parent based on child validity */
.form-group:has(input:user-invalid) {
  background: #fff5f5;
  padding: 1rem;
  border-radius: 8px;
}

.form-group:has(input:user-valid) {
  background: #f0fff4;
}

With Container Queries

.form-container {
  container-type: inline-size;
}

@container (max-width: 400px) {
  /* Compact validation UI on small containers */
  input:user-invalid + .error-message {
    font-size: 0.75rem;
    padding: 0.25rem;
  }
}

Conclusion

:user-valid and :user-invalid represent a significant improvement in form validation UX. They eliminate the need for complex CSS selectors and JavaScript workarounds, providing a better experience for users.

Key benefits:

  • Shows validation at the right time
  • Reduces user frustration
  • Simplifies CSS code
  • Works with native HTML validation

While browser support is still growing, progressive enhancement strategies make it safe to use today. Start implementing these pseudo-classes to create more user-friendly forms.


Browser support snapshot

Live support matrix for form-validation from Can I Use.

Show static fallback image Data on support for form-validation across major browsers from caniuse.com

Source: caniuse.com

Canvas is not supported in your browser