Selección de Padres: El Caso de Uso Obvio

El ejemplo canónico, y todavía el caso de uso más común:

/* Estilar el contenedor card cuando contiene una imagen */
.card:has(> img) {
 grid-template-rows: 200px 1fr;
}

.card:not(:has(> img)) {
 grid-template-rows: 1fr;
}

Antes de :has(), esto requería o dos variantes de card en tu biblioteca de componentes (.card--con-imagen, .card--solo-texto) o JavaScript que chequeara la imagen y agregara una clase. Ambos son cargas de mantenimiento. La solución CSS reacciona al contenido real.

Un ejemplo más práctico, un formulario donde el contenedor del botón submit cambia layout según si existen errores de validación:

.form-actions:has(~ .error-message) {
 flex-direction: column;
 gap: 0.5rem;
}

.form-actions:has(~ .error-message)::before {
 content: "Por favor corregí los errores de arriba";
 color: var(--color-error);
 font-size: 0.875rem;
}

El combinador de hermano general ~ dentro de :has() es lo que hace que funcione, el elemento form actions chequea si algún hermano siguiente es un mensaje de error. Sin JavaScript. Sin state management. CSS puro respondiendo a la estructura del DOM.

Quantity Queries: Implementación Nativa en CSS

Las quantity queries te dejan estilar un contenedor diferente según cuántos hijos tiene. Esto era un problema resuelto en Sass con mixins que generaban combinaciones de :nth-child + :nth-last-child, pero :has() lo hace nativo y legible.

/* Un solo item, ancho completo */
.grid:has(> :nth-child(1):last-child) {
 grid-template-columns: 1fr;
}

/* 2 items, dos columnas */
.grid:has(> :nth-child(2):last-child) {
 grid-template-columns: repeat(2, 1fr);
}

/* 3 items, tres columnas */
.grid:has(> :nth-child(3):last-child) {
 grid-template-columns: repeat(3, 1fr);
}

/* 4+ items, grid auto-fill */
.grid:has(> :nth-child(4)) {
 grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}

El truco es el patrón :nth-child(n):last-child. :nth-child(3):last-child coincide con un elemento que es tanto el tercer hijo COMO el último hijo, o sea que el contenedor tiene exactamente 3 hijos.

Este patrón funciona bien en dashboards donde la cantidad de cards de KPI varía por rol de usuario. Antes, los componentes React calculaban una prop columns y la pasaban como variable CSS. Ahora el CSS lo maneja autónomamente:

.kpi-dashboard:has(> .kpi-card:nth-child(5)) {
 /* 5+ cards: switchear a layout compacto */
 --kpi-gap: 0.75rem;
 --kpi-font-size: 0.875rem;
}

.kpi-dashboard:has(> .kpi-card:nth-child(5)) .kpi-card {
 padding: 0.75rem;
}

Queries de Rango

También es posible apuntar a rangos, "entre 3 y 6 items":

/* Tiene al menos 3 hijos Y el 7mo hijo NO existe */
.list:has(> :nth-child(3)):not(:has(> :nth-child(7))) {
 /* 3-6 items */
 columns: 2;
}

Esto se lee como: "un .list que tiene un 3er hijo (así que al menos 3) pero no tiene un 7mo hijo (así que como mucho 6)." Es verboso, pero funciona sin preprocesador.

Estilos Conscientes de Hermanos

:has() combinado con combinadores de hermanos te deja estilar elementos basándote en lo que viene después de ellos. Esto era completamente imposible antes.

/* Estilar un heading distinto cuando le sigue un subtítulo */
h2:has(+ .subtitle) {
 margin-bottom: 0.25rem;
}

h2:not(:has(+ .subtitle)) {
 margin-bottom: 1.5rem;
}

/* Estilar el último item antes de un divisor */
.nav-item:has(+ .nav-divider) {
 margin-bottom: 0;
 padding-bottom: 0.5rem;
 border-bottom: 1px solid var(--border);
}

Este patrón resulta valioso en rediseños de navegación. El approach viejo requiere clases .nav-item--antes-de-divisor generadas por el template engine. La versión con :has() puede eliminar decenas de líneas de lógica de template.

Cascada de Estados de Formularios

Este es el punto donde :has() empieza a sentirse como si estuviera reemplazando JavaScript entero. Es posible cascadear estados de elementos de formulario hacia arriba a cualquier ancestro:

/* Toda la sección del form recibe feedback visual basado en validez */
.form-section:has(:invalid) {
 border-left: 3px solid var(--color-error);
}

.form-section:has(:focus-within) {
 background: var(--bg-active);
}

/* Label del fieldset cambia cuando algún input hijo tiene contenido */
.field-group:has(input:not(:placeholder-shown)) label {
 transform: translateY(-1.5rem) scale(0.85);
 color: var(--color-primary);
}

/* Deshabilitar submit cuando el form tiene campos inválidos */
form:has(:invalid) button[type="submit"] {
 opacity: 0.5;
 pointer-events: none;
}

Ese último, deshabilitar submit basado en validez del form, antes requería un hook useEffect mirando cada campo del formulario. Ahora es una regla CSS.

El Hack del Checkbox, Evolucionado

El clásico hack del checkbox (:checked + combinador de hermano ~) estaba limitado a estilar hermanos. Con :has(), es posible estilar cualquier cosa en el documento basándote en el estado del checkbox:

/* Toggle de dark mode, cero JavaScript */
html:has(#theme-toggle:checked) {
 --bg: #1a1816;
 --text: #e8e6e3;
 --accent: #d97757;
 color-scheme: dark;
}

/* Colapso de sidebar */
.layout:has(#sidebar-toggle:checked) .sidebar {
 transform: translateX(-100%);
}

.layout:has(#sidebar-toggle:checked) .main-content {
 grid-column: 1 / -1;
}

Esto no es un argumento para reemplazar toda la interactividad JavaScript con hacks de checkbox. Pero para toggles binarios que afectan estado puramente visual, este approach es más limpio que un React context provider.

Patrones Responsivos Habilitados por :has()

Un patrón notable, layout responsivo que se adapta al contenido, no al viewport:

/* Contenedor con sidebar opcional */
.page-layout {
 display: grid;
 grid-template-columns: 1fr;
}

.page-layout:has(> .sidebar) {
 grid-template-columns: 1fr 300px;
}

/* Pero colapsar a columna única si el sidebar está vacío */
.page-layout:has(> .sidebar:empty) {
 grid-template-columns: 1fr;
}

/* Y switchear basado en densidad de contenido */
.page-layout:has(> .sidebar > :nth-child(5)) {
 grid-template-columns: 1fr 400px;
 /* Sidebar más ancho cuando tiene 5+ items */
}

Esto responde al contenido real del sidebar, no a una prop, no a una clase, no a un media query. El layout emerge de la estructura del contenido.

Otro más, estilos condicionales de tabla basados en cantidad de columnas:

/* Tabla compacta cuando tiene muchas columnas */
table:has(th:nth-child(8)) {
 font-size: 0.75rem;
}

table:has(th:nth-child(8)) th,
table:has(th:nth-child(8)) td {
 padding: 0.25rem 0.5rem;
}

/* Highlight de fila en hover, pero solo si la tabla tiene columna interactiva */
table:has(th.actions) tbody tr:hover {
 background: var(--row-hover);
 cursor: pointer;
}

Consideraciones de Performance

Ahora la parte que importa a escala. :has() es una pseudo-clase relacional, el browser tiene que evaluar el selector argumento para cada elemento que matchee el sujeto. Esto tiene costos reales.

Al profilar una página con 2.000 nodos DOM y varios selectores :has(), se obtienen estos resultados de Chrome DevTools Selector Stats:

.card:has(> img) → 0.3ms (rápido, hijo directo)
.card:has(img) → 1.8ms (lento, búsqueda descendiente)
.grid:has(:nth-child(4):last-child) → 0.4ms (rápido, estructural)
.page:has(.error) → 3.2ms (lento, descendiente profundo)
.a:has(.b:has(.c)) → 8.7ms (evitar esto)

Las reglas de oro:

  1. Utilizar combinadores de hijo directo (>) siempre que puedas. La diferencia entre :has(> img) y :has(img) es aproximadamente 6x en benchmarks.

  2. Evitar :has() en subárboles que se actualizan frecuentemente. Si un nodo DOM cambia seguido (listas virtuales, feeds en tiempo real), cada mutación dispara re-evaluación de :has() para todos los ancestros.

  3. No combines :has() con *. El selector .parent:has(* > .child) es patológico, recorre todo el subárbol en cada nivel.

  4. Profileá antes de optimizar. En la mayoría de aplicaciones reales, el matching de selectores CSS es un error de redondeo comparado con layout y paint. No evites :has() por miedo infundado.

/* BIEN, scope restringido */
.card:has(> .badge) { /* ... */ }

/* MAL, búsqueda descendiente sin restricción */
.page:has(.badge) { /* ... */ }

/* TERRIBLE, :has() anidado con búsqueda descendiente */
.page:has(.section:has(.card:has(.badge))) { /* ... */ }

Estrategia de Mejora Progresiva

Para el caso raro donde se necesita soportar browsers sin :has():

/* Estilos base, funcionan en todos lados */
.card {
 grid-template-rows: 1fr;
}

/* Estilos mejorados, solo en browsers con :has() */
@supports selector(:has(*)) {
 .card:has(> img) {
 grid-template-rows: 200px 1fr;
 }
}

El query @supports selector() ya tiene buen soporte a esta altura, haciendo este patrón seguro.

Para Cerrar

:has() es la feature de CSS más significativa desde Flexbox. No es hipérbole, cambia fundamentalmente la relación entre CSS y la estructura del DOM. Patrones que requerían JavaScript (selección de padres), preprocesadores (quantity queries), o que eran simplemente imposibles (awareness de hermanos hacia adelante) ahora son CSS nativo.

El uso de :has() en producción desde mediados de 2024 ha demostrado beneficios medibles. Los codebases que lo adoptan pueden reducir bundles JavaScript en 10-15KB eliminando código de DOM-querying que ahora vive en CSS. Los selectores son legibles, la performance es aceptable, y la carga de mantenimiento es dramáticamente menor.

Escribir el CSS que describe la relación entre tus elementos. Dejar que el browser se encargue del resto.