Especificidad, invalidez y los límites del argumento
Cuánto pesa :has() en la cascada, por qué su lista no perdona errores al contrario que :is(), y el catálogo exacto de cosas que su argumento no puede contener.
:has() hereda de :is() la regla de especificidad más contraintuitiva del lenguaje: la pseudo-clase no pesa nada, pero su contenido pesa entero. Un identificador escondido en el argumento convierte una regla de clase en una regla que solo !important puede tumbar. Y a diferencia de :is(), si te equivocas escribiendo el argumento no se descarta la parte mala: se descarta la regla completa, en silencio.
- Calcular la especificidad de un selector que contiene
:has()sin equivocarte. - Neutralizar el peso del argumento con
:where()cuando escribes CSS de librería. - Distinguir las listas indulgentes de las que no lo son y predecir qué se descarta.
- Enumerar lo que el argumento de
:has()no admite y por qué en cada caso.
El argumento pesa entero
La regla es la misma que para :is() y :not(): la pseudo-clase aporta cero, y se suma la especificidad del selector más específico de su argumento. El resto de entradas de la lista no cuentan, aunque sean las que acaben encajando.
/* (0,1,0) de .card + (0,1,0) de .destacada = (0,2,0) */
.card:has(.destacada) { }
/* (0,1,0) de .card + (1,0,0) de #promo = (1,1,0). Casi imposible de vencer. */
.card:has(#promo, .destacada) { }
/* (0,1,0) de .card + (0,0,1) de img = (0,1,1) */
.card:has(> img) { }
El tercer ejemplo enseña algo importante: los combinadores no aportan especificidad, ni dentro ni fuera de :has(). Da igual que escribas :has(img) o :has(> section > figure > img); lo que cuenta es la suma de los compuestos, y el más específico de esa cadena es lo que se suma al total.
El segundo ejemplo es el que arruina hojas de estilo. #promo está ahí porque alguien quería cubrir un caso concreto, y de paso ha subido la regla entera a la columna de los identificadores. Cualquier intento posterior de sobrescribir .card con otra clase fracasará por una razón que no está a la vista en el selector que falla, sino en otro archivo.
La cura es :where(), que tiene especificidad cero por definición y no la recupera nunca, pase lo que pase dentro:
/* (0,1,0). Ni mas ni menos, aunque el argumento tenga un identificador. */
.card:has(:where(#promo, .destacada)) { }
Esta es la forma canónica de escribir :has() en CSS que van a consumir otros: la condición se evalúa igual, pero el consumidor puede sobrescribirte con una sola clase. Si estás escribiendo estilos de aplicación y controlas todo el archivo, no hace falta; si estás escribiendo una librería, es obligatorio.
| Pseudo-clase | Tipo de lista | ¿Indulgente? | Especificidad que aporta |
|---|---|---|---|
:is() |
compleja | sí | la del argumento más específico |
:where() |
compleja | sí | cero, siempre |
:not() |
compleja | no | la del argumento más específico |
:has() |
relativa | no | la del argumento más específico |
Lo que pasa cuando te equivocas
Una lista indulgente descarta las entradas que el motor no entiende y sigue adelante con las demás. :is(.a, :pseudo-que-no-existe, .b) encaja perfectamente con .a y con .b. Ese comportamiento existe para que puedas escribir una regla que use un selector nuevo sin que los navegadores viejos tiren toda la línea.
:has() no es indulgente. Su argumento es una lista de selectores relativos normal y corriente, y si una entrada no se puede parsear, la declaración entera se vuelve inválida. No la entrada: la regla.
/* Si el motor no reconoce :una-cosa-nueva, esta regla NO existe. */
.card:has(> img, :una-cosa-nueva) { border: 1px solid; }
/* Aqui si: la lista interior es indulgente, la exterior sigue viva. */
.card:has(> :is(img, :una-cosa-nueva)) { border: 1px solid; }
flowchart TB
A[El motor lee el argumento de has] --> B{Entiende todas las entradas}
B -- Si --> C[La regla se aplica]
B -- No --> D[La regla completa se descarta]
D --> E[Envolver con is o where para tolerar]
E --> C
style C fill:#a6e3a1,color:#11111b
style D fill:#f38ba8,color:#11111b
style E fill:#89b4fa,color:#11111bEsa dureza no es un descuido. La primera versión de la especificación definía el argumento como indulgente, y se cambió a propósito. El motivo es la detección de características: si :has() tragase cualquier cosa sin quejarse, @supports selector(:has(a)) devolvería true en un navegador que no implementa nada, porque la comprobación se limitaría a parsear. Al hacerlo estricto, la pregunta se vuelve honesta y el fallback se vuelve escribible.
Hay un detalle histórico que explica lo raro de esta pseudo-clase. La especificación llegó a marcarla como válida solo en el perfil estático —el que usan querySelector y matches, donde el árbol no cambia mientras se evalúa— y prohibida en las hojas de estilo, porque se daba por hecho que no era implementable en vivo. Que hoy funcione en CSS es la consecuencia de que esa suposición resultó ser falsa.
Lo que el argumento no admite
Cuatro prohibiciones, cada una con su razón.
No puedes anidar :has() dentro de :has(). Ni directamente ni a través de :is() o :not(). .a:has(.b:has(.c)) es inválido, y también lo es .a:has(:is(.b:has(.c))). El motivo es de coste: cada nivel de anidamiento multiplica el trabajo de invalidación, y el grupo de trabajo prefirió cerrar la puerta antes que dejar que alguien escribiera una regla capaz de congelar una pestaña.
No puedes usar pseudo-elementos. :has(::before) es inválido. Un pseudo-elemento no es un nodo del árbol: es una caja generada durante el layout a partir de una declaración. Preguntar si un elemento “contiene un ::before” no tiene sentido en el momento en que se resuelven los selectores, porque los ::before aún no existen.
:visited nunca encaja dentro de :has(). Escribirlo es sintácticamente válido, pero el motor lo trata como si no hubiera encajado nunca. Si funcionara, li:has(a:visited) permitiría cambiar el layout del contenedor según el historial de navegación, y con un poco de ingenio eso se lee desde JavaScript. La misma mitigación que limita qué propiedades acepta :visited desde hace años se extiende aquí, pero de forma más radical: no es que se limiten las propiedades, es que la condición no se cumple jamás.
No cruza los límites del shadow DOM. Un :has() escrito en el documento principal no ve dentro de un shadow root, y uno escrito dentro de un shadow root no ve fuera. La encapsulación del shadow DOM es anterior y más fuerte que el selector relacional, y :has() no le abre ningún agujero.
.campo:has(input:checked) funciona: :checked es un estado vivo que el motor actualiza cuando el usuario interactúa. .campo:has(input[value="hola"]) no funciona como esperas: el atributo value del HTML guarda el valor inicial, no el actual, y no se modifica cuando alguien escribe. Lo mismo vale para checked como atributo frente a :checked como pseudo-clase. Si tu :has() depende de datos que el usuario cambia, tiene que apoyarse en pseudo-clases de estado, nunca en selectores de atributo.
Poner el sujeto en su sitio
La última fuente de sorpresas no está en el argumento sino en el sujeto. :has() no restringe dónde se puede escribir: *:has(.x) es legal, y significa “cualquier elemento que contenga un .x”, lo cual incluye html, body, y todos los contenedores intermedios. Ese selector encaja con una cadena entera de ancestros a la vez, y no suele ser lo que quieres.
/* Encaja con html, body, main, section... todos a la vez. */
*:has(.error) { }
/* Encaja solo con el contenedor que te interesa. */
.formulario:has(.error) { }
/* Y si de verdad quieres la raiz, dilo. */
html:has(.error) { --tono-global: crisis; }
Anclar el sujeto a una clase concreta no es solo higiene: es la diferencia entre un conjunto de invalidación acotado y uno que abarca medio documento. Volveremos a ello con números en la lección de rendimiento, pero la regla se puede adoptar ya: :has() siempre a la derecha de algo concreto.
Hay un fallo de arquitectura que solo aparece en proyectos grandes y que casi nadie relaciona con su causa. Cuando escribes .layout:has(.sidebar--fija), has creado un acoplamiento invisible entre dos componentes que viven en archivos distintos y que ningún grep va a relacionar: el día que alguien renombre .sidebar--fija, el layout dejará de reaccionar y no habrá ningún error, ningún aviso y ninguna regla rota. Simplemente el contenedor se quedará con su estilo por defecto para siempre. Es la primera vez en la historia de CSS que la definición de un componente puede depender de un detalle interno de otro componente sin que exista ninguna referencia en la dirección contraria. La disciplina que lo resuelve es tratar lo que va dentro de :has() como una interfaz pública: usa atributos con nombre estable y semántica declarada —[data-estado="error"], [aria-expanded], :checked— en lugar de clases de presentación, porque un atributo con significado documentado sobrevive a los rediseños y una clase de estilo no. Y envuélvelo en :where() para que además no arrastre especificidad. La regla completa, la que deberías pegar en la guía de estilo del equipo: .contenedor:has(:where([data-estado="x"])). Especificidad de una clase, dependencia sobre un contrato explícito, y cero sorpresas dentro de seis meses.
- Calcula a mano la especificidad de
#app .lista li:has(> a[href^="/"]:focus-visible). - Reescríbelo para que pese exactamente lo mismo que
.lista li. - Explica por qué
:is(.a:has(.b))es válido y:has(.a:has(.b))no lo es. - Construye un caso donde
:has(input[value])dé un falso negativo y arréglalo. - Escribe la versión con fallback de una regla que use
:has(), de forma que un navegador que no lo soporte reciba un estilo razonable en lugar de ninguno.