Los pseudo-elementos de cada control y las pseudo-clases de estado
El catálogo real de las piezas internas que cada control expone, y por qué :user-invalid es la pseudo-clase que arregla el formulario que gritaba en rojo antes de que escribieras nada.
Los pseudo-elementos de formulario son el mapa de las grietas: los puntos concretos donde cada motor decidió, en un momento distinto y por razones distintas, dejarte tocar una pieza interna. No hay simetría, no hay coherencia entre motores y la mitad llevan prefijo de vendedor. Aun así son imprescindibles, porque son la única forma de tocar el botón de un input type=file o la pista de un range. Y junto a ellos van las pseudo-clases de estado, donde una elección de una sola palabra —:invalid frente a :user-invalid— decide si tu formulario es agresivo o civilizado.
- Conocer qué pieza expone cada control y con qué nombre en cada motor.
- Estilar
::file-selector-buttony::placeholdercon sus limitaciones reales. - Distinguir
:invalidde:user-invalidy saber por qué la segunda existe. - Combinar las pseudo-clases de estado con
:has()para estilar el contenedor del campo.
El catálogo, motor por motor
Estos son los pseudo-elementos que existen de verdad. Los estándar van sin prefijo y funcionan en los cuatro motores; los demás son grietas específicas de un motor y hay que escribirlos en reglas separadas, porque un selector desconocido invalida toda la lista de selectores y perderías la regla entera.
| Pieza | Estándar | Chromium | Gecko |
|---|---|---|---|
| Texto de ayuda | ::placeholder |
— | — |
Botón de input type=file |
::file-selector-button |
— | — |
Barra de progress |
— | ::-webkit-progress-bar |
— |
Valor de progress |
— | ::-webkit-progress-value |
::-moz-progress-bar |
Pista de range |
— | ::-webkit-slider-runnable-track |
::-moz-range-track |
Pulgar de range |
— | ::-webkit-slider-thumb |
::-moz-range-thumb |
Recorrido de range |
— | — | ::-moz-range-progress |
Botón de borrar de search |
— | ::-webkit-search-cancel-button |
— |
| Icono del selector de fecha | — | ::-webkit-calendar-picker-indicator |
— |
La asimetría del range es la que más duele: Chromium te da pista y pulgar pero no el recorrido, así que la parte “rellena” a la izquierda del pulgar hay que fabricarla con un gradiente que se actualiza desde JavaScript o con accent-color, que sí la pinta. Gecko te da el recorrido pero con otros nombres. No hay forma de escribir un range totalmente personalizado con una sola regla, y quien te diga lo contrario no lo ha probado en los dos motores.
/* Una regla por motor. Nunca en la misma lista de selectores. */
input[type="range"]::-webkit-slider-runnable-track {
block-size: 0.375rem;
border-radius: 999px;
background: color-mix(in oklch, currentColor 20%, transparent);
}
input[type="range"]::-moz-range-track {
block-size: 0.375rem;
border-radius: 999px;
background: color-mix(in oklch, currentColor 20%, transparent);
}
::file-selector-button, el que sí merece la pena
Es el único pseudo-elemento con prefijo que se estandarizó de verdad y funciona igual en todos lados. Selecciona el botón que abre el diálogo de archivos, y se comporta como un botón normal: acepta padding, border, background, font y transiciones.
input[type="file"] {
font: inherit;
}
input[type="file"]::file-selector-button {
font: inherit;
padding: 0.4em 0.9em;
margin-inline-end: 0.75em;
border: 1px solid currentColor;
border-radius: 0.4em;
background: transparent;
color: inherit;
cursor: pointer;
}
input[type="file"]::file-selector-button:hover {
background: color-mix(in oklch, currentColor 10%, transparent);
}
Lo que no puedes tocar es el texto de “Ningún archivo seleccionado” que aparece al lado, ni su posición relativa al botón, ni sustituirlo por el nombre del archivo con un formato tuyo. Ese texto lo genera el agente de usuario, está localizado y no hay pseudo-elemento para él. Si el diseño lo exige, la única solución honesta sigue siendo ocultar el input visualmente —sin display: none, que lo saca del orden de tabulación— y usar una label estilada como botón, leyendo files desde JavaScript para pintar el nombre.
Con ::placeholder pasa algo parecido y conviene decirlo claro: acepta un subconjunto pequeño de propiedades, esencialmente las de color y tipografía. No acepta padding, ni display, ni transformaciones. Y hay una restricción de accesibilidad que no es negociable: un placeholder no es una etiqueta. Desaparece al escribir, tiene contraste bajo por defecto y los lectores de pantalla lo tratan de forma inconsistente. Si has bajado la opacidad de tu ::placeholder porque el gris del sistema te parecía fuerte, probablemente estés por debajo del contraste mínimo.
:invalid es una trampa; :user-invalid es la respuesta
La validación de HTML tiene dos familias de pseudo-clases y la diferencia entre ellas es de comportamiento, no de sintaxis.
:invalid y :valid reflejan el estado de validez en cada instante, desde que la página carga. Un campo required vacío es inválido antes de que el usuario lo haya visto. Un input type=email es inválido en cuanto escribes la primera letra, y sigue siéndolo hasta que la dirección está completa. Estilar con :invalid produce el formulario que todos hemos sufrido: todo rojo antes de empezar, y cada campo gritando mientras lo rellenas.
:user-valid y :user-invalid reflejan el estado de validez solo después de que el usuario haya interactuado con el control. La interacción cuenta cuando el usuario ha editado el campo y ha salido de él, o cuando ha intentado enviar el formulario. Antes de eso, no coinciden con nada. Es exactamente el comportamiento que la gente reimplementaba a mano con una clase .touched puesta desde JavaScript en el evento blur, y que ahora es una pseudo-clase estándar disponible en los cuatro motores desde hace años.
/* mal: agrede desde el primer fotograma */
input:invalid { border-color: crimson; }
/* bien: solo tras la interaccion del usuario */
input:user-invalid {
border-color: oklch(0.58 0.2 25);
background: oklch(0.58 0.2 25 / 0.06);
}
input:user-valid {
border-color: oklch(0.6 0.15 150);
}
Hay dos matices que conviene tener presentes. El primero es que :user-invalid sí se activa al intentar enviar, lo que resuelve el caso del campo que el usuario nunca tocó: al pulsar el botón de envío, todos los campos inválidos pasan a coincidir a la vez. El segundo es que el mensaje de error nativo del navegador sigue apareciendo salvo que uses novalidate y lo gestiones tú; :user-invalid estila, no sustituye el mensaje.
Las demás pseudo-clases del grupo completan el cuadro: :required y :optional para marcar el campo antes de tocarlo, :in-range y :out-of-range para campos numéricos con min y max, :placeholder-shown para saber si el campo está vacío desde CSS, :disabled y :read-only, y :autofill para detectar que el navegador ha rellenado el campo —útil porque el autorrelleno aplica su propio fondo amarillo y suele romper los diseños oscuros.
Un borde rojo comunica exactamente cero información a quien no distingue el rojo del verde, y nada en absoluto a quien usa un lector de pantalla. La señal accesible es textual y está asociada al campo: un mensaje con id referenciado desde aria-describedby, y aria-invalid="true" puesto por el mismo código que decide que hay error. :user-invalid es la capa visual encima de eso, no el mecanismo.
Estilar el contenedor, no solo el campo
El problema clásico de estas pseudo-clases es que solo alcanzan al propio control, y lo que quieres teñir casi siempre es la fila entera: la etiqueta, el mensaje, el icono. Antes hacía falta una clase puesta desde JavaScript en el contenedor. Con :has() eso desapareció:
.campo:has(input:user-invalid) {
--tono-borde: oklch(0.58 0.2 25);
}
.campo:has(input:user-invalid) .mensaje-error {
display: block;
}
.campo:has(input:required) .etiqueta::after {
content: " *";
color: var(--tono-borde, currentColor);
}
/* La etiqueta flotante, sin una sola linea de JavaScript */
.campo:has(input:placeholder-shown:not(:focus)) .etiqueta {
translate: 0 1.4em;
scale: 1.1;
}
El patrón general es siempre el mismo: el control es la fuente de verdad del estado, :has() lo propaga hacia arriba, y una custom property lo distribuye hacia abajo. Ningún estado vive en JavaScript, ninguna clase se sincroniza, y no hay forma de que la interfaz se desincronice del valor real del control, porque no hay dos copias del estado.
:invalid llegó con la validación de formularios de HTML5, alrededor de 2010, y desde el primer día todo el mundo se dio cuenta de que era inservible para estilar. La pregunta interesante no es por qué se diseñó mal, sino por qué costó tanto arreglarlo: porque el concepto que faltaba no era de CSS, era de interacción. :invalid es una función pura del valor del control y de sus restricciones; se puede evaluar en cualquier momento sin más contexto. :user-invalid no: exige que el motor mantenga un bit de estado por control que dice “este usuario ya ha tenido oportunidad de arreglar esto”, y ese bit depende de una definición de “interacción” que había que escribir en la especificación de HTML y que no es obvia. ¿Cuenta enfocar y salir sin escribir? ¿Cuenta pegar? ¿Cuenta que un script asigne value? ¿Se resetea al reiniciar el formulario? ¿Qué pasa con un campo que se vuelve required a mitad? Cada una de esas preguntas tuvo que responderse antes de que la pseudo-clase pudiera existir, y el resultado es una máquina de estados que el navegador mantiene por ti y que la implementación casera con .touched en el evento blur nunca replica del todo: la versión casera se equivoca al enviar, se equivoca al resetear y se equivoca con el autorrelleno. El patrón general que hay detrás vale para todo el CSS moderno: cuando una pseudo-clase tarda una década en aparecer, casi siempre es porque el estado que necesita no estaba definido en ningún sitio, y tu polyfill lo estaba adivinando.
Construye un componente de campo con etiqueta flotante, contador de caracteres restantes, mensaje de error y marca de requerido. La regla del ejercicio: no puedes añadir ni quitar ninguna clase desde JavaScript. El único JavaScript permitido es el que escribe el número del contador en una custom property. Todo lo demás —el flotado de la etiqueta, el color del borde, la aparición del mensaje— sale de :placeholder-shown, :focus-within, :user-invalid y :has().