El selector relacional: qué hace :has() de verdad
Cómo :has() cualifica un elemento por lo que hay a su alrededor sin mover el sujeto del selector, cuál es la gramática exacta de su argumento y por qué ningún motor lo implementó durante doce años.
Durante veinticinco años CSS solo supo mirar en dos direcciones: hacia abajo, hacia los descendientes, y hacia delante, hacia los hermanos posteriores. Podías estilar el hijo en función del padre, jamás el padre en función del hijo. Esa asimetría no era un descuido de la especificación: era la consecuencia directa del algoritmo con el que los motores emparejan selectores, y romperla exigía rehacer la maquinaria de invalidación de estilos entera. :has() la rompe.
- Explicar qué es el sujeto de un selector y por qué
:has()no lo cambia. - Leer y escribir listas de selectores relativos dentro de
:has(). - Justificar por qué ningún motor lo implementó durante más de una década.
- Situar el soporte real y la fecha desde la que dejó de ser una apuesta.
El sujeto de un selector
Todo selector complejo tiene un sujeto: el elemento que acabará recibiendo las declaraciones. En CSS clásico el sujeto es siempre el último compuesto de la cadena, sin excepción.
/* El sujeto es .card en los tres casos. Lo que va antes solo cualifica. */
.card { border: 1px solid; }
article .card { border-color: currentColor; }
article > section + aside .card { border-style: dashed; }
Esa regla fija tiene una consecuencia brutal: si quieres estilar article en función de si contiene o no una .card, no hay forma de escribirlo. El sujeto siempre está a la derecha, y a la derecha no puedes poner el ancestro.
:has() no cambia esa regla. Sigue siendo cierto que el sujeto es el último compuesto. Lo que hace :has() es añadir una condición al compuesto que mira hacia fuera en lugar de hacia dentro. Escrito así, deja de parecer magia:
/* Sujeto: figure. Condicion: que contenga un figcaption. */
figure:has(figcaption) { padding-bottom: 0.5rem; }
/* Sujeto: article. Condicion: que NO contenga ninguna .card. */
article:not(:has(.card)) { display: none; }
Llamarlo “selector de padre” es cómodo pero engañoso, porque sugiere que solo sirve para subir un nivel. :has() es un selector relacional: cualifica al sujeto por la existencia de otro elemento en cualquier posición alcanzable desde él con los combinadores normales. Descendientes, hijos directos, hermanos posteriores. Y como el sujeto se queda quieto, con + obtienes gratis algo que CSS nunca tuvo: el hermano anterior.
/* El elemento de la lista que va justo ANTES del que tiene el foco dentro. */
li:has(+ li:focus-within) { border-bottom-color: transparent; }
flowchart LR A[Selector complejo] --> B[El sujeto es siempre el ultimo compuesto] B --> C[Los combinadores solo permiten bajar o avanzar] C --> D[has anade una condicion al sujeto sin moverlo] D --> E[Por eso aparece el hermano anterior] style D fill:#a6e3a1,color:#11111b style E fill:#a6e3a1,color:#11111b
La gramática del argumento
El argumento de :has() no es una lista de selectores normal. Es una lista de selectores relativos: cada entrada puede empezar por un combinador, y si no lo hace se asume el descendiente.
/* Descendiente implicito: en cualquier profundidad. */
figure:has(figcaption) { }
/* Hijo directo, y solo hijo directo. */
figure:has(> figcaption) { }
/* Hermano inmediatamente posterior. */
h2:has(+ p) { }
/* Cualquier hermano posterior, no solo el siguiente. */
h2:has(~ .destacado) { }
/* Lista separada por comas: basta con que una encaje. */
.card:has(> img, > video, > svg) { }
Los cuatro combinadores se admiten y se combinan con lo que quieras dentro. El argumento puede ser tan complejo como cualquier otro selector, incluyendo pseudo-clases de estado, lo que abre la puerta a reglas que reaccionan a la interacción sin una línea de JavaScript.
/* Un formulario que sabe que alguien esta escribiendo dentro. */
form:has(input:focus) { outline: 2px solid; outline-offset: 4px; }
/* Un contenedor que sabe cuantos hijos tiene: si existe el sexto, hay al menos seis. */
.galeria:has(> :nth-child(6)) { columns: 3; }
Hay tres límites duros que conviene memorizar ahora y no descubrir depurando. :has() no puede anidarse dentro de otro :has(): :has(:has(...)) es inválido y tira la regla entera. No admite pseudo-elementos en su argumento: :has(::before) es inválido, porque los pseudo-elementos no son elementos del árbol. Y :visited dentro de :has() nunca encaja, por la misma razón de privacidad que impide leer el historial desde CSS: si encajara, el estilo del ancestro filtraría qué páginas has visitado.
A diferencia de :is() y :where(), que aceptan listas indulgentes y descartan en silencio las entradas que no entienden, la lista de :has() no es indulgente. Si escribes .card:has(> img, > ::marker) y el motor no reconoce una de las entradas, la regla completa se descarta. Es una decisión deliberada del grupo de trabajo: se cambió a no indulgente precisamente para que @supports selector(:has(a)) pudiera servir como detección fiable. Si necesitas tolerancia, envuelve el interior: .card:has(> :is(img, video)).
Por qué tardó doce años
La idea es vieja. Selectors Level 4 llevaba desde principios de los 2010 buscando una forma de mover el sujeto: hubo una propuesta con un indicador explícito, donde escribías algo como ul! > li para marcar cuál de los compuestos recibía los estilos. Se abandonó y se sustituyó por :has(), con la sintaxis que jQuery ya popularizaba desde hacía años. Y ahí se quedó, en la especificación, sin una sola implementación, con la etiqueta oficiosa de “el selector imposible”.
La razón es el algoritmo de emparejamiento. Los motores no evalúan los selectores de izquierda a derecha, como los lees: los evalúan de derecha a izquierda. Dado un elemento y una regla, se comprueba primero el compuesto más a la derecha, porque es el que puede descartar la regla más rápido; solo si encaja se sube por el árbol comprobando los ancestros. Ese orden hace que el coste de un selector dependa de lo lejos que llegue la comprobación, no de su longitud.
:has() invierte esa dirección. Para saber si article:has(.card) encaja con un article concreto hay que descender por su subárbol, que puede ser arbitrariamente grande. Pero el problema serio no es el emparejamiento inicial, sino la invalidación: cuando el DOM cambia, el motor tiene que decidir qué estilos recalcular. Sin :has(), añadir un nodo solo puede afectar al estilo de ese nodo y de sus descendientes. Con :has(), añadir un nodo puede cambiar el estilo de cualquier ancestro, y de los hermanos anteriores de cualquier ancestro. El conjunto de “estilos que pueden haber cambiado” deja de estar acotado hacia abajo.
Lo que desbloqueó la implementación fue resolver ese segundo problema, no el primero. Los motores marcan los elementos que participan como sujeto de algún :has() y precalculan conjuntos de invalidación indexados por lo que aparece en el argumento, de modo que un cambio en el DOM solo despierta a los ancestros que realmente podían verse afectados. Con esa maquinaria montada, el coste dejó de ser catastrófico y pasó a ser medible. Safari fue el primero en enviarlo, en 15.4, en marzo de 2022. Chrome lo siguió en la 105, en agosto del mismo año. Firefox cerró el círculo en la 121, en diciembre de 2023.
Qué puedes dar por hecho en 2026
:has() está ampliamente disponible en los cuatro motores desde finales de 2023. En 2026 puedes escribirlo sin fallback en cualquier proyecto que no tenga un compromiso explícito con navegadores anteriores a esa fecha. No es una función experimental ni un adelanto: es parte del vocabulario básico.
Cuando el compromiso existe, la detección es limpia y barata, porque :has() es no indulgente y por tanto selector() da una respuesta honesta:
/* Fallback primero: todas las tarjetas con la misma rejilla. */
.card { display: grid; grid-template-rows: auto; }
@supports selector(:has(*)) {
.card:has(> img) { grid-template-rows: 12rem 1fr; }
}
Y desde JavaScript, cuando necesitas ramificar el comportamiento y no solo el estilo:
const hayHas = CSS.supports('selector(:has(*))');
document.documentElement.classList.toggle('sin-has', !hayHas);
El uso que más rendimiento da a :has() no es estilar un contenedor por su contenido, que es el ejemplo de folleto. Es convertir cualquier estado del DOM en un interruptor legible desde toda la hoja de estilos. Cuando escribes html:has(dialog[open]) o body:has(.menu[aria-expanded="true"]), estás haciendo algo que antes solo podía hacer JavaScript: propagar un hecho local hacia arriba hasta la raíz, donde todo el CSS puede verlo. Combinado con custom properties heredadas, eso te da un canal de comunicación descendente que no cuesta ni un addEventListener: un componente enterrado a diez niveles de profundidad cambia de estado, la raíz lo detecta, redefine una variable, y el resto del documento reacciona. Antes de :has() ese patrón exigía un observador que subiera el estado a una clase en el elemento raíz, y con él venían el coste del listener, el riesgo de desincronización y la necesidad de que el JavaScript estuviera cargado. La lección profunda es que :has() no amplía el conjunto de elementos que puedes seleccionar: amplía el conjunto de hechos que la cascada puede observar. Deja de pensarlo como un selector y empieza a pensarlo como el sistema de eventos que CSS nunca tuvo.
- Escribe un selector que aplique a un
articlesolo si contiene al menos unh2y ninguna imagen. - Escribe uno que estile el
labelque precede inmediatamente a uninputinválido. - Explica por qué
:has(::first-line)es inválido, sin recurrir a “porque la especificación lo dice”. - Reescribe
.card:has(> img, > figure > img)para que sea tolerante a que el motor no entienda una de las dos entradas. - Mide con las herramientas de desarrollo si
*:has(.x)y.contenedor:has(.x)tienen el mismo coste de recálculo al insertar un nodo. Explica la diferencia.