:user-invalid frente a :invalid y el catálogo de estados
Por qué :invalid marca un formulario vacío desde el primer render, qué añade exactamente :user-invalid, y el catálogo completo de pseudo-clases de estado de los controles.
Si estilas un formulario con :invalid, todos los campos obligatorios aparecen en rojo antes de que el usuario haya escrito una sola letra. Es correcto según la definición de la pseudo-clase y es inaceptable según cualquier criterio de diseño de interacción. Durante años se resolvió con trucos —:placeholder-shown, clases de “tocado” gestionadas desde JavaScript, librerías enteras dedicadas a ello— hasta que la plataforma admitió que faltaba una distinción y añadió :user-invalid.
- Explicar por qué
:invalides correcto y aun así inservible para dar feedback. - Enunciar las dos condiciones que activan
:user-invalid. - Usar el catálogo completo de pseudo-clases de estado sin confundir las parejas.
- Montar un formulario con validación visual sin una línea de JavaScript.
Un formulario que grita antes de tiempo
:invalid encaja con cualquier control cuyo valor no satisfaga sus restricciones. Un input con required y sin valor no las satisface, así que encaja desde el primer render, antes de cualquier interacción.
<label>Correo <input type="email" required></label>
/* Correcto segun la especificacion, desastroso en pantalla. */
input:invalid { border-color: red; }
La definición no está mal: describe un hecho sobre los datos, y ese hecho es cierto. El error fue creer que ese hecho sirve para decidir cuándo hablarle al usuario. Son dos preguntas distintas: el dato es válido y ya toca decírselo.
Los apaños históricos intentaban aproximar la segunda a partir de la primera. El más extendido combinaba :placeholder-shown para detectar campos vacíos y :focus para no molestar mientras se escribe:
/* El apano de 2018. Funciona a medias y solo si hay placeholder. */
input:invalid:not(:focus):not(:placeholder-shown) { border-color: red; }
Tiene tres agujeros: exige un placeholder aunque no lo quieras, no distingue “vacío” de “escrito mal”, y no reacciona al intento de envío. Cualquier librería de formularios que hayas usado implementa una máquina de estados con touched y dirty precisamente para tapar esos agujeros.
Las dos condiciones de :user-invalid
:user-invalid encaja cuando el control es inválido y además ha ocurrido al menos una de estas dos cosas: el usuario ha interactuado de forma significativa con él, o se ha intentado enviar el formulario al que pertenece.
Esa segunda condición es la que ninguna aproximación casera cubría bien. Al pulsar el botón de envío, todos los campos inválidos del formulario pasan a encajar de golpe, hayan sido tocados o no. Es exactamente el comportamiento que espera cualquier persona que rellena un formulario: no me molestes mientras trabajo, dímelo todo cuando intente enviar.
/* Feedback en el momento correcto, sin placeholder y sin JavaScript. */
input:user-invalid { border-color: color-mix(in oklch, red 80%, canvas); }
input:user-valid { border-color: color-mix(in oklch, green 60%, canvas); }
:user-valid es su pareja simétrica y sigue la misma regla: solo confirma en verde lo que el usuario ya ha tocado, nunca los campos que aún no ha visto.
La distinción con :invalid no es que una sustituya a la otra. :invalid sigue siendo la correcta para estilar el estado del formulario en conjunto, porque ahí no hablas de un campo concreto sino de si el conjunto se puede enviar:
/* El estado del formulario: hecho sobre los datos. */
form:has(:invalid) [type="submit"] { opacity: 0.5; }
/* El feedback por campo: hecho sobre la conversacion. */
.campo:has(> :user-invalid) .mensaje-error { display: block; }
Emite siempre el mensaje de error en el HTML, oculto, y déjalo asociado al campo con aria-describedby. Con :user-invalid lo revelas en el momento justo sin tocar el DOM, y la asociación con el lector de pantalla ya está hecha desde el principio en lugar de crearse a posteriori. Es más simple y más correcto que insertar el mensaje desde JavaScript.
Sobre el soporte: Firefox lleva desde 2021 con la versión estándar, Safari lo envió en 16.5 y Chrome en la 119, a finales de 2023. En 2026 está ampliamente disponible y no necesita reserva. Si necesitas una, el fallback natural es :invalid acotado con :not(:placeholder-shown), que es peor pero no rompe nada.
El catálogo de estados
Las pseudo-clases de control van casi siempre en parejas, y la mitad de los errores vienen de confundir cuál es cuál.
| Pseudo-clase | Encaja con |
|---|---|
:required / :optional |
controles con y sin el atributo required |
:valid / :invalid |
el dato satisface o no las restricciones, desde el primer render |
:user-valid / :user-invalid |
lo mismo, pero solo tras interacción o intento de envío |
:in-range / :out-of-range |
numéricos con min o max, dentro o fuera del intervalo |
:enabled / :disabled |
el control acepta o no interacción |
:read-write / :read-only |
el usuario puede o no editar el valor |
:checked |
casilla, radio u opción seleccionada |
:indeterminate |
estado intermedio, no marcado ni desmarcado |
:default |
el control que está preseleccionado en el marcado |
:placeholder-shown |
hay un placeholder y se está mostrando |
:autofill |
el navegador ha rellenado el campo automáticamente |
Cuatro merecen aclaración porque su comportamiento no es el que sugiere el nombre.
:indeterminate encaja en tres situaciones distintas: una casilla cuya propiedad indeterminate se ha puesto a true desde JavaScript —no hay atributo HTML para eso—, un grupo de radios donde ninguno está marcado, y un progress sin atributo value. La segunda es la más útil y la menos conocida: te permite estilar un grupo de opciones antes de que se elija ninguna.
:default no es “el valor actual” sino “el que venía marcado en el HTML”. En un formulario es el botón de envío predeterminado, y en un grupo de opciones es la que traía checked o selected. Sirve para señalar la opción recomendada aunque el usuario haya elegido otra.
:read-only encaja con muchísimo más de lo que parece: no solo con los input que llevan el atributo, sino con cualquier elemento que no sea editable, incluido un párrafo cualquiera. Si escribes :read-only { opacity: 0.6 } sin acotarlo a un selector de control, atenúas la página entera.
:placeholder-shown exige que exista un placeholder. Para el patrón de etiqueta flotante, el truco es poner un placeholder de un solo espacio, placeholder=" ", para que la pseudo-clase funcione sin que se vea texto.
Un formulario completo
Todo junto, sin JavaScript, con el envoltorio reaccionando gracias a :has():
<form>
<div class="campo">
<input id="correo" type="email" required placeholder=" ">
<label for="correo">Correo electronico</label>
<p class="error" id="err-correo">Escribe una direccion valida.</p>
</div>
<button type="submit">Enviar</button>
</form>
.campo { position: relative; display: grid; gap: 0.25rem; }
.campo input {
padding-block: 1.25rem 0.5rem;
border: 1px solid;
border-radius: 0.375rem;
}
/* Etiqueta flotante: baja cuando el placeholder esta visible. */
.campo label {
position: absolute;
inset-block-start: 0.35rem;
inset-inline-start: 0.75rem;
font-size: 0.75rem;
transition: translate 150ms, font-size 150ms;
}
.campo input:placeholder-shown:not(:focus) + label {
translate: 0 0.75rem;
font-size: 1rem;
}
/* Obligatorio: marca visible generada desde el estado, no desde la plantilla. */
.campo:has(input:required) label::after { content: " *"; }
/* Error: solo tras interaccion o intento de envio. */
.campo .error { display: none; color: color-mix(in oklch, red 80%, canvas); }
.campo:has(input:user-invalid) .error { display: block; }
.campo:has(input:user-invalid) input { border-color: color-mix(in oklch, red 80%, canvas); }
/* Acierto confirmado, tambien solo tras interaccion. */
.campo:has(input:user-valid) input { border-color: color-mix(in oklch, green 55%, canvas); }
/* El boton refleja el estado real de los datos, no el de la conversacion. */
form:has(:invalid) [type="submit"] { opacity: 0.5; cursor: not-allowed; }
Fíjate en la última línea frente a las tres anteriores. El botón usa :invalid porque pregunta por los datos; los campos usan :user-invalid porque preguntan por el momento. Ese contraste es el resumen entero de la lección.
La lección de diseño que hay detrás de :user-invalid es más grande que los formularios y se puede formular así: la validación de restricciones tiene dos consumidores con necesidades opuestas y durante veinte años solo hubo una API para los dos. El primer consumidor es el navegador, que necesita saber en todo momento si el formulario se puede enviar; para él la respuesta tiene que ser inmediata, continua y libre de historia, y :invalid es exactamente eso. El segundo consumidor es la persona que rellena el formulario, y para ella la respuesta correcta depende de algo que el estado del dato no contiene: si ya ha tenido oportunidad de acertar. Un campo vacío que aún no has visto no es un error tuyo; el mismo campo vacío después de pulsar Enviar sí lo es. El dato es idéntico en los dos momentos; lo que cambia es la conversación. Cada librería de formularios que has usado —con sus touched, dirty, pristine, submitted— existe porque alguien tuvo que reconstruir en JavaScript ese contexto conversacional que el DOM ya conocía y no exponía. Y aquí está el matiz que solo se aprecia habiendo mantenido una de esas librerías: la versión de la plataforma es más correcta que la casera, porque el navegador sabe cosas que tu código no puede saber sin instrumentarlo todo —si el valor llegó del autocompletado, si el campo se rellenó desde el gestor de contraseñas, si el usuario navegó hacia atrás y el formulario se restauró—, y todas ellas cuentan como interacción significativa. Cuando la plataforma expone un estado que tú estabas simulando, migrar no es solo borrar código: normalmente es también corregir bugs que nunca habías identificado.
- Explica por qué un campo
requiredvacío encaja con:invalidnada más cargar la página. - Comprueba que al pulsar Enviar todos los campos inválidos pasan a
:user-invalidde golpe. - Estila un grupo de radios sin elegir usando
:indeterminate. - Explica qué le pasa a tu página si escribes
:read-only { opacity: 0.5 }sin acotarlo. - Monta el patrón de etiqueta flotante y comprueba que necesita
placeholder=" "para funcionar.