Lo que ya no necesita JavaScript, y lo que sí
El inventario de código que puedes borrar al adoptar :has(), los límites que el selector relacional no cruza, y cómo migrar una base existente sin romper nada.
En casi cualquier proyecto anterior a 2023 hay una capa de JavaScript cuya única función es contarle al CSS algo que el CSS ya podía haber mirado por sí mismo. Observadores que añaden clases, escuchadores de input que marcan formularios, funciones que cuentan hijos y ponen un modificador. Ese código no aporta comportamiento: aporta información. Y desde :has(), la información ya está disponible.
- Identificar en una base existente el JavaScript que solo existe para informar al CSS.
- Enumerar con precisión los límites que
:has()no cruza. - Combinar
:has()con capas, custom properties y el resto de la cascada. - Planificar una migración con detección de soporte y degradación razonable.
Lo que se puede borrar del proyecto
El patrón a cazar es siempre el mismo: JavaScript que lee el DOM, saca una conclusión booleana y escribe una clase. Tiene una firma reconocible.
// El tipo de codigo que sobra desde 2023.
const obs = new MutationObserver(() => {
for (const card of document.querySelectorAll('.card')) {
card.classList.toggle('card--con-imagen', !!card.querySelector(':scope > img'));
}
});
obs.observe(document.body, { childList: true, subtree: true });
Veintitrés líneas contando el disconnect y el manejo del caso inicial, un observador vivo durante toda la sesión, y una clase que hay que documentar para que nadie la escriba a mano. Sustituto exacto:
.card:has(> img) { grid-template-rows: 12rem auto 1fr; }
El inventario completo de lo que suele desaparecer:
El bloqueo de desplazamiento. Casi todas las librerías de modales traen su propio gestor de overflow en el body, con la contabilidad de modales anidados incluida. html:has(dialog[open]) no necesita contabilidad: si hay al menos un diálogo abierto, encaja.
La validación visual del formulario. El clásico escuchador de input que añade .tiene-errores al contenedor. form:has(:user-invalid) da lo mismo, y además usa la heurística nativa de “el usuario ya ha interactuado” que tendrías que reimplementar a mano.
Las clases de cantidad. El bucle que cuenta hijos y pone lista--muchos. Las consultas de cantidad con :nth-child() dentro de :has() lo cubren sin tocar el DOM.
Los estados vacíos. El if (items.length === 0) que decide qué renderizar. Con :not(:has(li)) puedes emitir siempre las dos ramas y dejar que el CSS elija, lo que además elimina un reflow al pasar de vacío a lleno.
La sincronización de estados entre componentes hermanos. El bus de eventos improvisado que avisa a la cabecera de que el panel lateral se ha abierto. body:has(.panel[data-abierto]) lo resuelve sin canal.
Busca en tu base de código classList.toggle, classList.add y setAttribute('class' y clasifica cada aparición en dos montones: las que expresan una acción del usuario o una decisión de la aplicación, que se quedan, y las que expresan un hecho observable del DOM, que se van. La proporción típica en un proyecto de cierta edad ronda el sesenta por ciento en el segundo montón.
Los límites que siguen ahí
:has() no es un motor de reglas general. Cinco fronteras que conviene tener claras antes de intentar cruzarlas.
No ve lo que el usuario escribe. Ya lo viste: los selectores de atributo leen el atributo del HTML, no la propiedad viva del elemento. :has(input[value]) no reacciona al teclado. Lo que sí funciona son las pseudo-clases de estado —:checked, :user-invalid, :placeholder-shown— porque esas sí las actualiza el motor. Para “el campo contiene exactamente la palabra X” sigue haciendo falta JavaScript.
No cruza el shadow DOM. Ni hacia dentro ni hacia fuera. Un componente encapsulado es opaco para :has(), en las dos direcciones, y eso es intencionado.
No mide. No existe ninguna forma de preguntar por el tamaño, la posición o el desbordamiento de un elemento desde un selector. :has() es una pregunta sobre la estructura del árbol, no sobre el resultado del layout. Detectar que un texto se ha desbordado sigue exigiendo ResizeObserver o IntersectionObserver.
No sube desde el argumento. El argumento es una lista de selectores relativos anclada al sujeto, y los selectores relativos solo saben bajar y avanzar. No puedes escribir dentro de un :has() una condición sobre los ancestros del sujeto; para eso ya tienes el combinador de descendiente por fuera.
No recuerda. Un :checked se pierde al recargar. Cualquier estado que deba persistir necesita almacenamiento, y por tanto JavaScript, aunque el JavaScript se reduzca a escribir un atributo en html durante el arranque y desaparecer.
flowchart TB
P[Quiero reaccionar a algo] --> Q{Ese algo esta en el arbol como nodo estado o atributo estable}
Q -- Si --> H[Usa has y borra el JavaScript]
Q -- No --> R{Depende del layout o de datos externos}
R -- Layout --> O[Observador de tamano o interseccion]
R -- Datos --> J[JavaScript que escribe un atributo en la raiz]
J --> H
style H fill:#a6e3a1,color:#11111b
style O fill:#f9e2af,color:#11111b
style J fill:#89b4fa,color:#11111bEse último camino del diagrama es el patrón que hay que interiorizar: cuando el hecho no está en el DOM, el trabajo de JavaScript se reduce a ponerlo en el DOM una vez, y a partir de ahí toda la reactividad la hace la cascada. Un atributo en html sustituye a cincuenta selectores manipulados desde código.
Combinarlo con la cascada
:has() gana potencia cuando deja de aplicar estilos y empieza a mover custom properties. El motivo es que una custom property se hereda, así que un solo :has() en un ancestro alto informa a todo el subárbol sin que ninguno de sus descendientes mencione la condición.
:root {
--densidad: 1;
--radio: 0.75rem;
}
/* Un solo selector relacional cambia el sistema entero. */
html:has(#compacto:checked) {
--densidad: 0.6;
--radio: 0.375rem;
}
.card { padding: calc(1rem * var(--densidad)); border-radius: var(--radio); }
.boton { padding-block: calc(0.5rem * var(--densidad)); }
.lista > li + li { margin-block-start: calc(0.75rem * var(--densidad)); }
Ninguna de las tres reglas de abajo sabe que existe una casilla llamada #compacto. Esa es exactamente la propiedad que quieres: la condición vive en un sitio, el efecto se propaga por herencia, y añadir un componente nuevo al sistema no exige tocar el interruptor.
La otra combinación que paga es con @layer. Las reglas con :has() suelen ser desvíos condicionales sobre un caso base, y arrastran la especificidad de su argumento. Meterlas en una capa posterior te libera de esa contabilidad:
@layer base, variantes;
@layer base {
.card { grid-template-rows: auto auto 1fr; }
}
@layer variantes {
/* Gana por capa, no por especificidad. El argumento puede pesar lo que quiera. */
.card:has(:where(> img)) { grid-template-rows: 12rem auto 1fr; }
}
Migrar sin romper
Escribe siempre el caso base primero, sin condición, y usa :has() solo para el desvío. Con ese orden, un navegador sin soporte no ve una regla rota: ve la regla base, que es completa y razonable. Es la misma disciplina que se aplica a cualquier mejora progresiva, y aquí es especialmente barata porque el caso base suele existir ya en el código que estás sustituyendo.
Cuando el desvío sea tan grande que el caso base no sea aceptable, detecta explícitamente y ramifica:
/* La comprobacion es honesta porque el argumento de :has() no es indulgente. */
@supports selector(:has(*)) {
.barra:has(> button:nth-child(6)) > button:nth-child(n+5) { display: none; }
.barra:has(> button:nth-child(6)) .desplegable { display: block; }
}
Y si el JavaScript que vas a borrar tenía además un comportamiento de reserva, apágalo desde el propio CSS en lugar de duplicar la detección:
// Una sola fuente de verdad para la deteccion.
if (!CSS.supports('selector(:has(*))')) {
import('./compat/has-fallback.js');
}
El orden de una migración real, por rendimiento decreciente: primero los interruptores globales en html y body, que borran librerías enteras y no tienen coste de invalidación; después la validación de formularios, que borra escuchadores y mejora la accesibilidad de paso; después los contenedores que se adaptan al contenido, que es donde más plantillas se simplifican; y al final las consultas de cantidad, que suelen ser las menos críticas y las que más conviene medir.
Hay una razón de rendimiento para preferir :has() a un MutationObserver que casi nadie enuncia, y es mucho más fuerte que “menos código”. Los callbacks de MutationObserver se ejecutan en el punto de comprobación de microtareas, es decir, después de que el motor haya procesado la mutación original. Si ese callback escribe una clase, acabas de ensuciar el estilo por segunda vez en el mismo fotograma: el navegador tiene que invalidar, recalcular estilo, y muy probablemente rehacer el layout otra vez, porque tu clase cambia la rejilla. Has convertido una pasada de estilo en dos, y si el callback lee alguna propiedad geométrica para decidir, has convertido dos en un ciclo de lectura y escritura forzadas que es el origen clásico del layout thrashing. Con :has() no hay segunda pasada: la condición se evalúa dentro del mismo recálculo de estilo que provocó la mutación, antes de que se construya nada de layout, y el resultado sale ya correcto de la primera pasada. Por eso la sustitución mejora el rendimiento incluso cuando el selector relacional es, aislado, más caro de evaluar que la consulta que hacía el observador: no estás comparando dos formas de calcular lo mismo, estás eliminando una pasada completa del pipeline de renderizado. Y por eso también, cuando midas una migración de este tipo, no mires solo el tiempo de recálculo de estilo: mira cuántos eventos de recálculo y de layout hay por interacción. Ese número es el que se parte por la mitad.
- Busca en un proyecto tuyo todos los
classList.toggley clasifícalos en acción o hecho observable. - Sustituye el primero de la segunda categoría por un
:has()y borra el código. - Escribe el caso base y el desvío para una regla que hoy dependa de una clase modificadora.
- Explica por qué
:has(input[value])no reacciona al teclado, en términos de atributo frente a propiedad. - Graba un perfil antes y después de una migración y compara el número de eventos de layout por interacción, no solo su duración.